1use crate::events::{ToolCallContext, VisitReason};
3use crate::{AgentEvent, AgentToolResult};
4use anyhow::Result;
5use futures::{FutureExt, StreamExt};
6use oxicode_ai::{AssistantMessage, Message, ToolCall, ToolResultMessage, progress_callback};
7use std::pin::Pin;
8use std::sync::Arc;
9use tokio::sync::Notify;
10
11fn make_cancellation(loop_ref: &super::AgentLoop) -> Arc<Notify> {
31 let notify = Arc::new(Notify::new());
32 if let Some(flag) = loop_ref.cancel_signal() {
33 let notify_for_task = Arc::clone(¬ify);
34 tokio::spawn(async move {
35 loop {
36 if flag.load(std::sync::atomic::Ordering::SeqCst) {
37 notify_for_task.notify_one();
38 return;
39 }
40 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
41 }
42 });
43 }
44 notify
45}
46
47use super::config::{AfterToolCallHook, ToolExecutionMode};
48use super::helpers::{FinalizedToolCall, create_tool_result_message, should_terminate_batch};
49use crate::tools::ToolContext as ToolExecContext;
50
51fn infer_context(tool_name: &str, args: &serde_json::Value) -> Option<ToolCallContext> {
59 match tool_name {
60 "web_search" => args["query"].as_str().map(|q| ToolCallContext::WebSearch {
61 query: q.into(),
62 engine: args["engines"].as_str().map(String::from),
63 }),
64
65 "browse" => args["url"].as_str().map(|u| ToolCallContext::PageVisit {
66 url: u.into(),
67 reason: Some(VisitReason::DirectNavigation),
68 page_title: None,
69 page_status: None,
70 page_bytes: None,
71 page_duration_ms: None,
72 navigation_error: None,
73 screenshot: None,
74 }),
75
76 "browse_extract" => Some(ToolCallContext::DataExtraction {
77 target: args["selector"].as_str().unwrap_or("data").to_string(),
78 url: args["url"].as_str().map(String::from),
79 result_count: None,
80 page_status: None,
81 page_duration_ms: None,
82 }),
83
84 "browse_session" => {
85 let action = args["action"].as_str().unwrap_or("unknown");
86 if action == "goto" {
88 args["url"].as_str().map(|u| ToolCallContext::PageVisit {
89 url: u.into(),
90 reason: Some(VisitReason::DirectNavigation),
91 page_title: None,
92 page_status: None,
93 page_bytes: None,
94 page_duration_ms: None,
95 navigation_error: None,
96 screenshot: None,
97 })
98 } else {
99 Some(ToolCallContext::SessionAction {
100 action: action.to_string(),
101 url: args["url"].as_str().map(String::from),
102 })
103 }
104 }
105
106 "browse_script" => {
107 let total = args["steps"].as_array().map(|a| a.len()).unwrap_or(0);
108 if total > 0 {
109 Some(ToolCallContext::ScriptStep {
110 current: 0,
111 total,
112 step: "starting".into(),
113 })
114 } else {
115 #[cfg(feature = "native-browser")]
118 {
119 args["script"]
120 .as_str()
121 .and_then(|s| serde_yaml::from_str::<serde_yaml::Value>(s).ok())
122 .as_ref()
123 .and_then(|v| v.get("steps").and_then(|s| s.as_sequence()))
124 .map(|steps| ToolCallContext::ScriptStep {
125 current: 0,
126 total: steps.len(),
127 step: "starting".into(),
128 })
129 }
130 #[cfg(not(feature = "native-browser"))]
131 {
132 None
133 }
134 }
135 }
136
137 _ => None,
138 }
139}
140
141fn enrich_context_from_metadata(
144 context_cell: &Arc<parking_lot::Mutex<Option<ToolCallContext>>>,
145 result: &AgentToolResult,
146) {
147 if let Some(ref meta) = result.metadata
148 && let Some(count) = meta.get("result_count").and_then(|v| v.as_u64())
149 {
150 let mut guard = context_cell.lock();
151 if let Some(ToolCallContext::DataExtraction { result_count, .. }) = &mut *guard {
152 *result_count = Some(count as usize);
153 }
154 }
155}
156
157fn make_browse_enrichment_cb(
168 context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>>,
169) -> crate::tools::browse::BrowseProgressCallback {
170 Arc::new(move |progress: crate::tools::browse::BrowseProgress| {
171 let mut guard = context_cell.lock();
172 match (&mut *guard, &progress) {
173 (
174 Some(ToolCallContext::PageVisit {
175 url,
176 page_title,
177 page_status,
178 page_bytes,
179 page_duration_ms,
180 ..
181 }),
182 crate::tools::browse::BrowseProgress::DocumentReady {
183 url: ready_url,
184 title,
185 status,
186 bytes,
187 duration_ms,
188 },
189 ) => {
190 if url != ready_url {
192 *url = ready_url.clone();
193 }
194 *page_title = Some(title.clone());
195 *page_status = Some(*status);
196 *page_bytes = Some(*bytes);
197 *page_duration_ms = Some(*duration_ms);
198 }
199 (
200 Some(ToolCallContext::DataExtraction {
201 page_status,
202 page_duration_ms,
203 ..
204 }),
205 crate::tools::browse::BrowseProgress::DocumentReady {
206 status,
207 duration_ms,
208 ..
209 },
210 ) => {
211 *page_status = Some(*status);
212 *page_duration_ms = Some(*duration_ms);
213 }
214
215 (
217 Some(ToolCallContext::PageVisit {
218 navigation_error, ..
219 }),
220 crate::tools::browse::BrowseProgress::NavigationFailed { error, .. },
221 ) => {
222 *navigation_error = Some(error.clone());
223 }
224
225 (
227 Some(ToolCallContext::PageVisit { screenshot, .. }),
228 crate::tools::browse::BrowseProgress::ScreenshotCaptured {
229 bytes,
230 width,
231 duration_ms,
232 },
233 ) => {
234 *screenshot = Some(crate::events::ScreenshotMeta {
235 bytes: *bytes,
236 width: *width,
237 duration_ms: *duration_ms,
238 });
239 }
240
241 _ => {}
242 }
243 })
244}
245
246pub(crate) struct ExecutedToolCallBatch {
247 pub messages: Vec<ToolResultMessage>,
248 pub terminate: bool,
249}
250
251enum FinalizedToolCallEntry {
252 Immediate(Box<FinalizedToolCall>),
253 Future(Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>),
254}
255
256pub(crate) struct ExecutedToolCallOutcome {
257 pub result: AgentToolResult,
258 pub is_error: bool,
259}
260
261enum PreparedToolCallKind {
262 Immediate,
263 Prepared,
264}
265
266struct PreparedToolCallOutcome {
267 _kind: PreparedToolCallKind,
268 immediate_result: Option<AgentToolResult>,
269 is_error: bool,
270 tool: Option<Arc<dyn crate::tools::AgentTool>>,
271 tool_call: ToolCall,
272 args: serde_json::Value,
273}
274
275async fn check_tool_approval(
281 loop_ref: &super::AgentLoop,
282 tool_call_id: &str,
283 tool_name: &str,
284 args: &serde_json::Value,
285 emit: &super::EmitFn,
286) -> Option<AgentToolResult> {
287 use crate::agent_loop::config::ApprovalDecision;
288
289 let config = &loop_ref.config.approval_config;
290
291 if config.require_approval_for.is_empty() {
293 return None;
294 }
295
296 let hook = config.hook.as_ref()?;
298
299 let tier = loop_ref
301 .tools
302 .get(tool_name)
303 .map(|t| t.tool_tier())
304 .unwrap_or(crate::tools::ToolTier::Exec);
305
306 if !config.require_approval_for.contains(&tier) {
308 return None;
309 }
310
311 match hook(tool_name, args).await {
312 Ok(ApprovalDecision::Allow) => None,
313 Ok(ApprovalDecision::Deny(reason)) => {
314 Some(AgentToolResult::error(format!("Access denied: {}", reason)))
315 }
316 Ok(ApprovalDecision::RequireApproval(reason)) => {
317 emit(AgentEvent::ApprovalRequired {
318 tool_call_id: tool_call_id.to_string(),
319 tool_name: tool_name.to_string(),
320 args: args.clone(),
321 reason: reason.clone(),
322 session_id: loop_ref.session_id.clone(),
323 });
324 Some(AgentToolResult::error(format!(
325 "Approval required: {}",
326 reason
327 )))
328 }
329 Err(e) => {
330 tracing::warn!(
331 tool = %tool_name,
332 error = %e,
333 "Approval hook failed, allowing tool call"
334 );
335 None }
337 }
338}
339
340pub(crate) async fn execute_tool_calls(
341 loop_ref: &super::AgentLoop,
342 messages: &mut Vec<Message>,
343 assistant_message: &AssistantMessage,
344 tool_calls: Vec<ToolCall>,
345 emit: &super::EmitFn,
346 ctx: &ToolExecContext,
347) -> Result<ExecutedToolCallBatch> {
348 if loop_ref.config.tool_execution == ToolExecutionMode::Sequential {
349 execute_tool_calls_sequential(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
350 .await
351 } else {
352 execute_tool_calls_parallel(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
353 .await
354 }
355}
356
357async fn execute_tool_calls_sequential(
358 loop_ref: &super::AgentLoop,
359 _messages: &mut Vec<Message>,
360 _assistant_message: &AssistantMessage,
361 tool_calls: Vec<ToolCall>,
362 emit: &super::EmitFn,
363 ctx: &ToolExecContext,
364) -> Result<ExecutedToolCallBatch> {
365 let mut finalized_calls = Vec::new();
366 let mut tool_result_messages = Vec::new();
367
368 for tool_call in tool_calls {
369 if loop_ref.is_cancelled() {
373 tracing::info!(
374 "[TOOL-EXEC] Cancelled before executing tool {}",
375 tool_call.name
376 );
377 break;
378 }
379 let tc_id = tool_call.id.clone();
381 let tc_name = tool_call.name.clone();
382 let tc_args = tool_call.arguments.clone();
383
384 let intent = loop_ref
385 .tools
386 .get(&tc_name)
387 .and_then(|t| Some(t.intent()?.to_string()));
388
389 emit(AgentEvent::ToolExecutionStart {
390 tool_call_id: tc_id.clone(),
391 tool_name: tc_name.clone(),
392 args: tc_args.clone(),
393 intent: intent.clone(),
394 context: infer_context(&tc_name, &tc_args),
395 });
396
397 if let Some(approval_result) =
399 check_tool_approval(loop_ref, &tc_id, &tc_name, &tc_args, emit).await
400 {
401 let finalized = FinalizedToolCall {
402 tool_call,
403 result: approval_result,
404 is_error: true,
405 };
406
407 let end_intent = loop_ref
408 .tools
409 .get(&tc_name)
410 .and_then(|t| Some(t.intent()?.to_string()));
411
412 emit(AgentEvent::ToolExecutionEnd {
413 tool_call_id: finalized.tool_call.id.clone(),
414 tool_name: finalized.tool_call.name.clone(),
415 intent: end_intent,
416 result: oxicode_ai::ToolResult {
417 tool_call_id: finalized.tool_call.id.clone(),
418 content: finalized.result.output.clone(),
419 status: String::from("error"),
420 },
421 is_error: true,
422 });
423
424 let tool_result_message = create_tool_result_message(&finalized);
425 let msg = Message::ToolResult(tool_result_message.clone());
426 emit(AgentEvent::MessageStart {
427 message: msg.clone(),
428 });
429 emit(AgentEvent::MessageEnd { message: msg });
430
431 finalized_calls.push(finalized);
432 tool_result_messages.push(tool_result_message);
433 continue;
434 }
435
436 let prepared = prepare_tool_call(loop_ref, &tool_call).await;
437
438 let finalized = if let Some(result) = prepared.immediate_result {
439 FinalizedToolCall {
440 tool_call,
441 result,
442 is_error: prepared.is_error,
443 }
444 } else {
445 let executed = execute_prepared_tool_call(loop_ref, &prepared, emit, ctx).await;
446
447 let mut result = executed.result;
448 let mut is_error = executed.is_error;
449
450 if let Some(hook) = &loop_ref.after_tool_call {
451 match hook(&tc_name, &result).await {
452 Ok(Some(modified)) => {
453 if let Some(ref details) = modified.metadata {
454 tracing::debug!(
455 tool = %tc_name,
456 details = %details,
457 "after_tool_call hook returned details"
458 );
459 }
460 result = modified;
461 is_error = !result.success;
462 }
463 Ok(None) => {}
464 Err(hook_err) => {
465 tracing::warn!(
466 tool = %tc_name,
467 error = %hook_err,
468 "after_tool_call hook failed, creating error result"
469 );
470 result = AgentToolResult::error(format!(
471 "after_tool_call hook failed: {hook_err}"
472 ));
473 is_error = true;
474 }
475 }
476 }
477
478 FinalizedToolCall {
479 tool_call,
480 result,
481 is_error,
482 }
483 };
484
485 let end_intent = finalized.result.intent.clone().or(intent);
486
487 emit(AgentEvent::ToolExecutionEnd {
488 tool_call_id: finalized.tool_call.id.clone(),
489 tool_name: finalized.tool_call.name.clone(),
490 intent: end_intent,
491 result: oxicode_ai::ToolResult {
492 tool_call_id: finalized.tool_call.id.clone(),
493 content: finalized.result.output.clone(),
494 status: if finalized.is_error {
495 String::from("error")
496 } else {
497 String::from("success")
498 },
499 },
500 is_error: finalized.is_error,
501 });
502
503 let tool_result_message = create_tool_result_message(&finalized);
504 let msg = Message::ToolResult(tool_result_message.clone());
505 emit(AgentEvent::MessageStart {
506 message: msg.clone(),
507 });
508 emit(AgentEvent::MessageEnd { message: msg });
509
510 finalized_calls.push(finalized);
511 tool_result_messages.push(tool_result_message);
512 }
513
514 Ok(ExecutedToolCallBatch {
515 messages: tool_result_messages,
516 terminate: should_terminate_batch(&finalized_calls),
517 })
518}
519
520async fn execute_tool_calls_parallel(
521 loop_ref: &super::AgentLoop,
522 _messages: &mut Vec<Message>,
523 _assistant_message: &AssistantMessage,
524 tool_calls: Vec<ToolCall>,
525 emit: &super::EmitFn,
526 ctx: &ToolExecContext,
527) -> Result<ExecutedToolCallBatch> {
528 let mut finalized_calls: Vec<FinalizedToolCallEntry> = Vec::new();
529
530 for tool_call in tool_calls {
531 if loop_ref.is_cancelled() {
533 tracing::info!(
534 "[TOOL-EXEC-PARALLEL] Cancelled before preparing tool {}",
535 tool_call.name
536 );
537 break;
538 }
539 let tc_id = tool_call.id.clone();
541 let tc_name = tool_call.name.clone();
542 let tc_args = tool_call.arguments.clone();
543
544 let intent = loop_ref
545 .tools
546 .get(&tc_name)
547 .and_then(|t| Some(t.intent()?.to_string()));
548
549 emit(AgentEvent::ToolExecutionStart {
550 tool_call_id: tc_id.clone(),
551 tool_name: tc_name.clone(),
552 args: tc_args.clone(),
553 intent: intent.clone(),
554 context: infer_context(&tc_name, &tc_args),
555 });
556
557 if let Some(approval_result) =
559 check_tool_approval(loop_ref, &tc_id, &tc_name, &tc_args, emit).await
560 {
561 let finalized = FinalizedToolCall {
562 tool_call,
563 result: approval_result,
564 is_error: true,
565 };
566
567 let end_intent = loop_ref
568 .tools
569 .get(&tc_name)
570 .and_then(|t| Some(t.intent()?.to_string()));
571
572 emit(AgentEvent::ToolExecutionEnd {
573 tool_call_id: finalized.tool_call.id.clone(),
574 tool_name: finalized.tool_call.name.clone(),
575 intent: end_intent,
576 result: oxicode_ai::ToolResult {
577 tool_call_id: finalized.tool_call.id.clone(),
578 content: finalized.result.output.clone(),
579 status: String::from("error"),
580 },
581 is_error: true,
582 });
583
584 let tool_result_message = create_tool_result_message(&finalized);
585 let msg = Message::ToolResult(tool_result_message.clone());
586 emit(AgentEvent::MessageStart {
587 message: msg.clone(),
588 });
589 emit(AgentEvent::MessageEnd { message: msg });
590
591 finalized_calls.push(FinalizedToolCallEntry::Immediate(Box::new(finalized)));
592 continue;
593 }
594
595 let prepared = prepare_tool_call(loop_ref, &tool_call).await;
596
597 if let Some(result) = prepared.immediate_result {
598 let finalized = FinalizedToolCall {
599 tool_call,
600 result,
601 is_error: prepared.is_error,
602 };
603
604 let end_intent = finalized.result.intent.clone().or(intent);
605
606 emit(AgentEvent::ToolExecutionEnd {
607 tool_call_id: finalized.tool_call.id.clone(),
608 tool_name: finalized.tool_call.name.clone(),
609 intent: end_intent,
610 result: oxicode_ai::ToolResult {
611 tool_call_id: finalized.tool_call.id.clone(),
612 content: finalized.result.output.clone(),
613 status: if finalized.is_error {
614 String::from("error")
615 } else {
616 String::from("success")
617 },
618 },
619 is_error: finalized.is_error,
620 });
621
622 finalized_calls.push(FinalizedToolCallEntry::Immediate(Box::new(finalized)));
623 } else {
624 let tool = prepared.tool.clone();
625 let args = prepared.args.clone();
626 let after_hook = loop_ref.after_tool_call.clone();
627 let emit_clone = emit.clone();
628 let ctx_clone = ctx.clone();
629 let cancel_notify = make_cancellation(loop_ref);
637
638 finalized_calls.push(FinalizedToolCallEntry::Future(Box::pin(async move {
639 let executed = execute_prepared_tool_call_static(
640 tool_call.clone(),
641 tool,
642 args,
643 after_hook.clone(),
644 emit_clone.clone(),
645 &ctx_clone,
646 Some(cancel_notify),
647 )
648 .await;
649
650 FinalizedToolCall {
651 tool_call,
652 result: executed.result,
653 is_error: executed.is_error,
654 }
655 })));
656 }
657 }
658
659 let mut slots: Vec<Option<FinalizedToolCall>> = Vec::with_capacity(finalized_calls.len());
660 #[allow(clippy::type_complexity)]
661 let mut pending_futures: Vec<(
662 usize,
663 Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>,
664 )> = Vec::new();
665
666 for (i, entry) in finalized_calls.into_iter().enumerate() {
667 match entry {
668 FinalizedToolCallEntry::Immediate(f) => slots.push(Some(*f)),
669 FinalizedToolCallEntry::Future(f) => {
670 slots.push(None);
671 pending_futures.push((i, f));
672 }
673 }
674 }
675
676 if !pending_futures.is_empty() {
677 let mut active = futures::stream::FuturesUnordered::new();
681 for (i, f) in pending_futures {
682 active.push(async move { (i, f.await) });
683 }
684
685 let mut cancel_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
688 cancel_interval.tick().await; loop {
691 tokio::select! {
692 result = active.next() => {
693 match result {
694 Some((idx, finalized)) => {
695 slots[idx] = Some(finalized);
696 }
697 None => break, }
699 }
700 _ = cancel_interval.tick() => {
701 if loop_ref.is_cancelled() {
702 tracing::info!(
703 "[TOOL-EXEC-PARALLEL] Cancelled during parallel execution, waiting for {} pending futures",
704 active.len()
705 );
706 break;
709 }
710 }
711 }
712 }
713
714 while let Some(result) = active.next().now_or_never().flatten() {
716 slots[result.0] = Some(result.1);
717 }
718 }
719
720 let ordered_finalized_calls: Vec<FinalizedToolCall> = slots.into_iter().flatten().collect();
722
723 let mut tool_result_messages = Vec::new();
724 for finalized in &ordered_finalized_calls {
725 let tool_result_message = create_tool_result_message(finalized);
726 let msg = Message::ToolResult(tool_result_message.clone());
727 emit(AgentEvent::MessageStart {
728 message: msg.clone(),
729 });
730 emit(AgentEvent::MessageEnd { message: msg });
731 tool_result_messages.push(tool_result_message);
732 }
733
734 Ok(ExecutedToolCallBatch {
735 messages: tool_result_messages,
736 terminate: should_terminate_batch(&ordered_finalized_calls),
737 })
738}
739
740pub(crate) async fn execute_prepared_tool_call_static(
741 tool_call: ToolCall,
742 tool: Option<Arc<dyn crate::tools::AgentTool>>,
743 args: serde_json::Value,
744 after_hook: Option<AfterToolCallHook>,
745 emit: Arc<dyn Fn(AgentEvent) + Send + Sync>,
746 ctx: &ToolExecContext,
747 cancel_notify: Option<Arc<Notify>>,
748) -> ExecutedToolCallOutcome {
749 let tool_call_id = tool_call.id.clone();
750 let tool_name = tool_call.name.clone();
751 let static_intent = tool.as_ref().and_then(|t| Some(t.intent()?.to_string()));
752
753 let mut result = AgentToolResult::success("");
754 let mut is_error = false;
755
756 if let Some(ref tool) = tool {
757 let context = infer_context(&tool_name, &args);
759
760 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
762 Arc::new(parking_lot::Mutex::new(context));
763
764 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
766 Arc::new(parking_lot::Mutex::new(None));
767 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
768
769 let emit_for_cb = emit.clone();
771 let tcid = tool_call_id.clone();
772 let tn = tool_name.clone();
773 let cc = Arc::clone(&context_cell);
774 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
775 let tab_id = *tab_id_slot.lock();
776 let ctx = cc.lock().clone();
777 emit_for_cb(AgentEvent::ToolExecutionUpdate {
778 tool_call_id: tcid.clone(),
779 tool_name: tn.clone(),
780 partial_result: msg,
781 tab_id,
782 context: ctx,
783 });
784 });
785 tool.on_progress(progress_callback(move |msg: String| {
786 progress_cb(msg);
787 }));
788
789 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
791 let exec_fut = tool.execute(&tool_call_id, args, None, ctx);
795 tokio::pin!(exec_fut);
796 let exec_result: Result<AgentToolResult, String> = match cancel_notify {
797 Some(notify) => {
798 let notify_for_select = Arc::clone(¬ify);
799 tokio::select! {
800 r = &mut exec_fut => r,
801 _ = notify_for_select.notified() => Err(format!(
802 "tool '{}' cancelled by agent loop",
803 tool_call_id
804 )),
805 }
806 }
807 None => exec_fut.await,
808 };
809 match exec_result {
810 Ok(r) => result = r,
811 Err(e) => {
812 result = AgentToolResult::error(e);
813 is_error = true;
814 }
815 }
816
817 enrich_context_from_metadata(&context_cell, &result);
818
819 tool.on_progress(progress_callback(|_| {}));
829 }
830
831 if let Some(hook) = &after_hook {
832 match hook(&tool_call.name, &result).await {
833 Ok(Some(modified)) => {
834 if let Some(details) = &modified.metadata {
835 tracing::debug!(
836 tool = %tool_call.name,
837 details = %details,
838 "after_tool_call hook returned details"
839 );
840 }
841 result = modified;
842 is_error = !result.success;
843 }
844 Ok(None) => {}
845 Err(hook_err) => {
846 tracing::warn!(
847 tool = %tool_call.name,
848 error = %hook_err,
849 "after_tool_call hook failed, creating error result"
850 );
851 result = AgentToolResult::error(format!("after_tool_call hook failed: {hook_err}"));
852 is_error = true;
853 }
854 }
855 }
856
857 let end_intent = result.intent.clone().or(static_intent);
858
859 emit(AgentEvent::ToolExecutionEnd {
860 tool_call_id: tool_call_id.clone(),
861 tool_name: tool_name.clone(),
862 intent: end_intent,
863 result: oxicode_ai::ToolResult {
864 tool_call_id,
865 content: result.output.clone(),
866 status: if is_error {
867 String::from("error")
868 } else {
869 String::from("success")
870 },
871 },
872 is_error,
873 });
874
875 ExecutedToolCallOutcome { result, is_error }
876}
877
878async fn prepare_tool_call(
879 loop_ref: &super::AgentLoop,
880 tool_call: &ToolCall,
881) -> PreparedToolCallOutcome {
882 let tool = match loop_ref.tools.get(&tool_call.name) {
883 Some(t) => t,
884 None => {
885 return PreparedToolCallOutcome {
886 _kind: PreparedToolCallKind::Immediate,
887 immediate_result: Some(AgentToolResult::error(format!(
888 "Tool '{}' not found",
889 tool_call.name
890 ))),
891 is_error: true,
892 tool: None,
893 tool_call: tool_call.clone(),
894 args: tool_call.arguments.clone(),
895 };
896 }
897 };
898
899 let validated_args = tool_call.arguments.clone();
900
901 if let Some(ref hook) = loop_ref.before_tool_call
902 && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
903 {
904 return PreparedToolCallOutcome {
905 _kind: PreparedToolCallKind::Immediate,
906 immediate_result: Some(blocked),
907 is_error: true,
908 tool: None,
909 tool_call: tool_call.clone(),
910 args: validated_args,
911 };
912 }
913
914 PreparedToolCallOutcome {
915 _kind: PreparedToolCallKind::Prepared,
916 immediate_result: None,
917 is_error: false,
918 tool: Some(Arc::clone(&tool)),
919 tool_call: tool_call.clone(),
920 args: validated_args,
921 }
922}
923
924async fn execute_prepared_tool_call(
925 loop_ref: &super::AgentLoop,
926 prepared: &PreparedToolCallOutcome,
927 emit: &super::EmitFn,
928 ctx: &ToolExecContext,
929) -> ExecutedToolCallOutcome {
930 let tool_call_id = prepared.tool_call.id.clone();
931 let tool_name = prepared.tool_call.name.clone();
932
933 let mut result = AgentToolResult::success("");
934 let mut is_error = false;
935
936 if let Some(ref tool) = prepared.tool {
937 let tool_call_id_clone = tool_call_id.clone();
938 let tool_name_clone = tool_name.clone();
939 let emit_clone = emit.clone();
940
941 let context = infer_context(&tool_name, &prepared.args);
943
944 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
947 Arc::new(parking_lot::Mutex::new(context));
948
949 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
953 Arc::new(parking_lot::Mutex::new(None));
954
955 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
957
958 let tab_id_slot_cb = Arc::clone(&tab_id_slot);
960 let cc = Arc::clone(&context_cell);
961 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
962 let tab_id = *tab_id_slot_cb.lock();
963 let ctx = cc.lock().clone();
964 emit_clone(AgentEvent::ToolExecutionUpdate {
965 tool_call_id: tool_call_id_clone.clone(),
966 tool_name: tool_name_clone.clone(),
967 partial_result: msg,
968 tab_id,
969 context: ctx,
970 });
971 });
972
973 tool.on_progress(progress_callback(move |msg: String| {
975 progress_cb(msg);
976 }));
977
978 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
981
982 let cancel_notify = make_cancellation(loop_ref);
1001 let cancel_for_select = Arc::clone(&cancel_notify);
1002 let tool_call_id_for_exec = tool_call_id.clone();
1003 let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
1004 tokio::pin!(exec_fut);
1005 let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
1006 let cancelled_msg_for_select = cancelled_msg.clone();
1007 let exec_result: Result<AgentToolResult, String> = tokio::select! {
1008 r = &mut exec_fut => r,
1009 _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
1010 };
1011 match exec_result {
1012 Ok(r) => result = r,
1013 Err(e) => {
1014 result = AgentToolResult::error(e);
1015 is_error = true;
1016 }
1017 }
1018
1019 enrich_context_from_metadata(&context_cell, &result);
1020
1021 tool.on_progress(progress_callback(|_| {}));
1025 }
1026
1027 ExecutedToolCallOutcome { result, is_error }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033 use serde_json::json;
1034
1035 #[test]
1036 fn infer_context_web_search() {
1037 let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
1038 assert!(matches!(
1039 ctx,
1040 Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
1041 ));
1042 }
1043
1044 #[test]
1045 fn infer_context_web_search_with_engine() {
1046 let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
1047 assert!(matches!(
1048 ctx,
1049 Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
1050 ));
1051 }
1052
1053 #[test]
1054 fn infer_context_browse() {
1055 let ctx = infer_context(
1056 "browse",
1057 &json!({ "url": "https://github.com/example/repo" }),
1058 );
1059 match ctx {
1060 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1061 assert_eq!(url, "https://github.com/example/repo");
1062 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1063 }
1064 other => panic!("expected PageVisit, got {:?}", other),
1065 }
1066 }
1067
1068 #[test]
1069 fn infer_context_browse_extract() {
1070 let ctx = infer_context(
1071 "browse_extract",
1072 &json!({ "url": "https://example.com", "selector": ".title" }),
1073 );
1074 match ctx {
1075 Some(ToolCallContext::DataExtraction { target, url, .. }) => {
1076 assert_eq!(target, ".title");
1077 assert_eq!(url.as_deref(), Some("https://example.com"));
1078 }
1079 other => panic!("expected DataExtraction, got {:?}", other),
1080 }
1081 }
1082
1083 #[test]
1084 fn infer_context_browse_session_goto() {
1085 let ctx = infer_context(
1086 "browse_session",
1087 &json!({ "action": "goto", "url": "https://example.com" }),
1088 );
1089 match ctx {
1090 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1091 assert_eq!(url, "https://example.com");
1092 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1093 }
1094 other => panic!("expected PageVisit, got {:?}", other),
1095 }
1096 }
1097
1098 #[test]
1099 fn infer_context_browse_session_click() {
1100 let ctx = infer_context(
1101 "browse_session",
1102 &json!({ "action": "click", "selector": "#btn" }),
1103 );
1104 match ctx {
1105 Some(ToolCallContext::SessionAction { action, url }) => {
1106 assert_eq!(action, "click");
1107 assert!(url.is_none());
1108 }
1109 other => panic!("expected SessionAction, got {:?}", other),
1110 }
1111 }
1112
1113 #[test]
1114 fn infer_context_browse_script_with_steps_array() {
1115 let ctx = infer_context(
1116 "browse_script",
1117 &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
1118 );
1119 match ctx {
1120 Some(ToolCallContext::ScriptStep {
1121 current,
1122 total,
1123 step,
1124 }) => {
1125 assert_eq!(current, 0);
1126 assert_eq!(total, 2);
1127 assert_eq!(step, "starting");
1128 }
1129 other => panic!("expected ScriptStep, got {:?}", other),
1130 }
1131 }
1132
1133 #[test]
1134 fn infer_context_browse_script_empty() {
1135 let ctx = infer_context("browse_script", &json!({ "script": "" }));
1136 #[cfg(feature = "native-browser")]
1137 assert!(ctx.is_none());
1138 #[cfg(not(feature = "native-browser"))]
1139 assert!(ctx.is_none());
1140 }
1141
1142 #[test]
1143 fn infer_context_unknown_tool() {
1144 let ctx = infer_context("bash", &json!({ "command": "ls" }));
1145 assert!(ctx.is_none());
1146 }
1147
1148 #[test]
1149 fn infer_context_missing_args() {
1150 let ctx = infer_context("browse", &json!({}));
1152 assert!(ctx.is_none());
1153
1154 let ctx = infer_context("web_search", &json!({}));
1156 assert!(ctx.is_none());
1157 }
1158
1159 #[test]
1160 fn tool_context_serde_roundtrip() {
1161 let contexts = vec![
1162 ToolCallContext::WebSearch {
1163 query: "test".into(),
1164 engine: Some("ddg".into()),
1165 },
1166 ToolCallContext::PageVisit {
1167 url: "https://example.com".into(),
1168 reason: Some(VisitReason::DirectNavigation),
1169 page_title: None,
1170 page_status: None,
1171 page_bytes: None,
1172 page_duration_ms: None,
1173 navigation_error: None,
1174 screenshot: None,
1175 },
1176 ToolCallContext::PageVisit {
1177 url: "https://example.com".into(),
1178 reason: Some(VisitReason::SearchResult { position: 3 }),
1179 page_title: None,
1180 page_status: None,
1181 page_bytes: None,
1182 page_duration_ms: None,
1183 navigation_error: None,
1184 screenshot: None,
1185 },
1186 ToolCallContext::PageVisit {
1187 url: "https://example.com".into(),
1188 reason: None,
1189 page_title: Some("Example Page".into()),
1190 page_status: Some(200),
1191 page_bytes: Some(12400),
1192 page_duration_ms: Some(245),
1193 navigation_error: None,
1194 screenshot: None,
1195 },
1196 ToolCallContext::DataExtraction {
1197 target: ".title".into(),
1198 url: Some("https://example.com".into()),
1199 result_count: None,
1200 page_status: None,
1201 page_duration_ms: None,
1202 },
1203 ToolCallContext::DataExtraction {
1204 target: ".items".into(),
1205 url: Some("https://shop.example.com/products".into()),
1206 result_count: Some(42),
1207 page_status: Some(200),
1208 page_duration_ms: Some(180),
1209 },
1210 ToolCallContext::SessionAction {
1211 action: "goto".into(),
1212 url: Some("https://example.com".into()),
1213 },
1214 ToolCallContext::ScriptStep {
1215 current: 3,
1216 total: 10,
1217 step: "clicking".into(),
1218 },
1219 ];
1220
1221 for ctx in &contexts {
1222 let json = serde_json::to_string(ctx).unwrap();
1223 let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1224 let json2 = serde_json::to_string(&restored).unwrap();
1225 assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1226 }
1227 }
1228
1229 #[test]
1230 fn tool_execution_update_backward_compat() {
1231 let old_json = json!({
1233 "type": "toolExecutionUpdate",
1234 "tool_call_id": "call_123",
1235 "tool_name": "browse",
1236 "partial_result": "Loading...",
1237 "tab_id": null
1238 });
1239 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1240 match event {
1241 crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1242 assert!(context.is_none());
1243 }
1244 other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1245 }
1246 }
1247
1248 #[test]
1249 fn tool_execution_start_backward_compat() {
1250 let old_json = json!({
1252 "type": "toolExecutionStart",
1253 "tool_call_id": "call_123",
1254 "tool_name": "browse",
1255 "args": { "url": "https://example.com" }
1256 });
1257 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1258 match event {
1259 crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1260 assert!(context.is_none());
1261 }
1262 other => panic!("expected ToolExecutionStart, got {:?}", other),
1263 }
1264 }
1265
1266 #[test]
1267 fn browse_enrichment_callback_fills_page_visit() {
1268 use crate::tools::browse::BrowseProgress;
1269 use std::sync::Arc;
1270
1271 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1272 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1273 url: "https://example.com".into(),
1274 reason: Some(VisitReason::DirectNavigation),
1275 page_title: None,
1276 page_status: None,
1277 page_bytes: None,
1278 page_duration_ms: None,
1279 navigation_error: None,
1280 screenshot: None,
1281 })));
1282 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1283 cb(BrowseProgress::DocumentReady {
1284 url: "https://example.com/final".into(),
1285 title: "Example".into(),
1286 status: 200,
1287 bytes: 4096,
1288 duration_ms: 245,
1289 });
1290 let snapshot = cell.lock().clone();
1291 match snapshot {
1292 Some(ToolCallContext::PageVisit {
1293 url,
1294 page_title,
1295 page_status,
1296 page_bytes,
1297 page_duration_ms,
1298 ..
1299 }) => {
1300 assert_eq!(url, "https://example.com/final");
1301 assert_eq!(page_title.as_deref(), Some("Example"));
1302 assert_eq!(page_status, Some(200));
1303 assert_eq!(page_bytes, Some(4096));
1304 assert_eq!(page_duration_ms, Some(245));
1305 }
1306 other => panic!("expected PageVisit, got {:?}", other),
1307 }
1308 }
1309
1310 #[test]
1311 fn browse_enrichment_callback_fills_data_extraction() {
1312 use crate::tools::browse::BrowseProgress;
1313 use std::sync::Arc;
1314
1315 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1316 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1317 target: ".item".into(),
1318 url: Some("https://shop.example.com".into()),
1319 result_count: None,
1320 page_status: None,
1321 page_duration_ms: None,
1322 })),
1323 );
1324 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1325 cb(BrowseProgress::DocumentReady {
1326 url: "https://shop.example.com".into(),
1327 title: "Shop".into(),
1328 status: 200,
1329 bytes: 8192,
1330 duration_ms: 180,
1331 });
1332 let snapshot = cell.lock().clone();
1333 match snapshot {
1334 Some(ToolCallContext::DataExtraction {
1335 page_status,
1336 page_duration_ms,
1337 ..
1338 }) => {
1339 assert_eq!(page_status, Some(200));
1340 assert_eq!(page_duration_ms, Some(180));
1341 }
1342 other => panic!("expected DataExtraction, got {:?}", other),
1343 }
1344 }
1345
1346 #[test]
1347 fn browse_enrichment_callback_no_op_for_mismatched() {
1348 use crate::tools::browse::BrowseProgress;
1349 use std::sync::Arc;
1350
1351 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1353 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1354 current: 1,
1355 total: 5,
1356 step: "click".into(),
1357 })));
1358 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1359 cb(BrowseProgress::DocumentReady {
1360 url: "x".into(),
1361 title: "t".into(),
1362 status: 200,
1363 bytes: 0,
1364 duration_ms: 0,
1365 });
1366 assert!(matches!(
1368 cell.lock().as_ref(),
1369 Some(ToolCallContext::ScriptStep { .. })
1370 ));
1371 }
1372
1373 #[test]
1374 fn browse_enrichment_callback_fills_navigation_error() {
1375 use crate::tools::browse::BrowseProgress;
1376 use std::sync::Arc;
1377
1378 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1379 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1380 url: "https://example.com".into(),
1381 reason: Some(VisitReason::DirectNavigation),
1382 page_title: None,
1383 page_status: None,
1384 page_bytes: None,
1385 page_duration_ms: None,
1386 navigation_error: None,
1387 screenshot: None,
1388 })));
1389 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1390 cb(BrowseProgress::NavigationFailed {
1391 url: "https://example.com".into(),
1392 error: "connection refused".into(),
1393 });
1394 let snapshot = cell.lock().clone();
1395 match snapshot {
1396 Some(ToolCallContext::PageVisit {
1397 navigation_error, ..
1398 }) => {
1399 assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1400 }
1401 other => panic!("expected PageVisit, got {:?}", other),
1402 }
1403 }
1404
1405 #[test]
1406 fn browse_enrichment_callback_fills_screenshot() {
1407 use crate::tools::browse::BrowseProgress;
1408 use std::sync::Arc;
1409
1410 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1411 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1412 url: "https://example.com".into(),
1413 reason: Some(VisitReason::DirectNavigation),
1414 page_title: None,
1415 page_status: None,
1416 page_bytes: None,
1417 page_duration_ms: None,
1418 navigation_error: None,
1419 screenshot: None,
1420 })));
1421 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1422 cb(BrowseProgress::ScreenshotCaptured {
1423 bytes: 2048,
1424 width: 800,
1425 duration_ms: 120,
1426 });
1427 let snapshot = cell.lock().clone();
1428 match snapshot {
1429 Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1430 let meta = screenshot.expect("screenshot should be set");
1431 assert_eq!(meta.bytes, 2048);
1432 assert_eq!(meta.width, 800);
1433 assert_eq!(meta.duration_ms, 120);
1434 }
1435 other => panic!("expected PageVisit, got {:?}", other),
1436 }
1437 }
1438
1439 #[test]
1440 fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1441 use crate::tools::browse::BrowseProgress;
1442 use std::sync::Arc;
1443
1444 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1446 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1447 target: ".title".into(),
1448 url: None,
1449 result_count: None,
1450 page_status: None,
1451 page_duration_ms: None,
1452 })),
1453 );
1454 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1455 cb(BrowseProgress::NavigationFailed {
1456 url: "https://example.com".into(),
1457 error: "timeout".into(),
1458 });
1459 assert!(matches!(
1460 cell.lock().as_ref(),
1461 Some(ToolCallContext::DataExtraction { .. })
1462 ));
1463 }
1464}