1use crate::events::{ToolCallContext, VisitReason};
3use crate::{AgentEvent, AgentToolResult};
4use anyhow::Result;
5use futures::{FutureExt, StreamExt};
6use oxi_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: oxi_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(ref hook) = loop_ref.after_tool_call
451 && let Some(modified) = hook(&tc_name, &result).await.ok().flatten()
452 {
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
464 FinalizedToolCall {
465 tool_call,
466 result,
467 is_error,
468 }
469 };
470
471 let end_intent = finalized.result.intent.clone().or(intent);
472
473 emit(AgentEvent::ToolExecutionEnd {
474 tool_call_id: finalized.tool_call.id.clone(),
475 tool_name: finalized.tool_call.name.clone(),
476 intent: end_intent,
477 result: oxi_ai::ToolResult {
478 tool_call_id: finalized.tool_call.id.clone(),
479 content: finalized.result.output.clone(),
480 status: if finalized.is_error {
481 String::from("error")
482 } else {
483 String::from("success")
484 },
485 },
486 is_error: finalized.is_error,
487 });
488
489 let tool_result_message = create_tool_result_message(&finalized);
490 let msg = Message::ToolResult(tool_result_message.clone());
491 emit(AgentEvent::MessageStart {
492 message: msg.clone(),
493 });
494 emit(AgentEvent::MessageEnd { message: msg });
495
496 finalized_calls.push(finalized);
497 tool_result_messages.push(tool_result_message);
498 }
499
500 Ok(ExecutedToolCallBatch {
501 messages: tool_result_messages,
502 terminate: should_terminate_batch(&finalized_calls),
503 })
504}
505
506async fn execute_tool_calls_parallel(
507 loop_ref: &super::AgentLoop,
508 _messages: &mut Vec<Message>,
509 _assistant_message: &AssistantMessage,
510 tool_calls: Vec<ToolCall>,
511 emit: &super::EmitFn,
512 ctx: &ToolExecContext,
513) -> Result<ExecutedToolCallBatch> {
514 let mut finalized_calls: Vec<FinalizedToolCallEntry> = Vec::new();
515
516 for tool_call in tool_calls {
517 if loop_ref.is_cancelled() {
519 tracing::info!(
520 "[TOOL-EXEC-PARALLEL] Cancelled before preparing tool {}",
521 tool_call.name
522 );
523 break;
524 }
525 let tc_id = tool_call.id.clone();
527 let tc_name = tool_call.name.clone();
528 let tc_args = tool_call.arguments.clone();
529
530 let intent = loop_ref
531 .tools
532 .get(&tc_name)
533 .and_then(|t| Some(t.intent()?.to_string()));
534
535 emit(AgentEvent::ToolExecutionStart {
536 tool_call_id: tc_id.clone(),
537 tool_name: tc_name.clone(),
538 args: tc_args.clone(),
539 intent: intent.clone(),
540 context: infer_context(&tc_name, &tc_args),
541 });
542
543 if let Some(approval_result) =
545 check_tool_approval(loop_ref, &tc_id, &tc_name, &tc_args, emit).await
546 {
547 let finalized = FinalizedToolCall {
548 tool_call,
549 result: approval_result,
550 is_error: true,
551 };
552
553 let end_intent = loop_ref
554 .tools
555 .get(&tc_name)
556 .and_then(|t| Some(t.intent()?.to_string()));
557
558 emit(AgentEvent::ToolExecutionEnd {
559 tool_call_id: finalized.tool_call.id.clone(),
560 tool_name: finalized.tool_call.name.clone(),
561 intent: end_intent,
562 result: oxi_ai::ToolResult {
563 tool_call_id: finalized.tool_call.id.clone(),
564 content: finalized.result.output.clone(),
565 status: String::from("error"),
566 },
567 is_error: true,
568 });
569
570 let tool_result_message = create_tool_result_message(&finalized);
571 let msg = Message::ToolResult(tool_result_message.clone());
572 emit(AgentEvent::MessageStart {
573 message: msg.clone(),
574 });
575 emit(AgentEvent::MessageEnd { message: msg });
576
577 finalized_calls.push(FinalizedToolCallEntry::Immediate(Box::new(finalized)));
578 continue;
579 }
580
581 let prepared = prepare_tool_call(loop_ref, &tool_call).await;
582
583 if let Some(result) = prepared.immediate_result {
584 let finalized = FinalizedToolCall {
585 tool_call,
586 result,
587 is_error: prepared.is_error,
588 };
589
590 let end_intent = finalized.result.intent.clone().or(intent);
591
592 emit(AgentEvent::ToolExecutionEnd {
593 tool_call_id: finalized.tool_call.id.clone(),
594 tool_name: finalized.tool_call.name.clone(),
595 intent: end_intent,
596 result: oxi_ai::ToolResult {
597 tool_call_id: finalized.tool_call.id.clone(),
598 content: finalized.result.output.clone(),
599 status: if finalized.is_error {
600 String::from("error")
601 } else {
602 String::from("success")
603 },
604 },
605 is_error: finalized.is_error,
606 });
607
608 finalized_calls.push(FinalizedToolCallEntry::Immediate(Box::new(finalized)));
609 } else {
610 let tool = prepared.tool.clone();
611 let args = prepared.args.clone();
612 let after_hook = loop_ref.after_tool_call.clone();
613 let emit_clone = emit.clone();
614 let ctx_clone = ctx.clone();
615 let cancel_notify = make_cancellation(loop_ref);
623
624 finalized_calls.push(FinalizedToolCallEntry::Future(Box::pin(async move {
625 let executed = execute_prepared_tool_call_static(
626 tool_call.clone(),
627 tool,
628 args,
629 after_hook.clone(),
630 emit_clone.clone(),
631 &ctx_clone,
632 Some(cancel_notify),
633 )
634 .await;
635
636 FinalizedToolCall {
637 tool_call,
638 result: executed.result,
639 is_error: executed.is_error,
640 }
641 })));
642 }
643 }
644
645 let mut slots: Vec<Option<FinalizedToolCall>> = Vec::with_capacity(finalized_calls.len());
646 #[allow(clippy::type_complexity)]
647 let mut pending_futures: Vec<(
648 usize,
649 Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>,
650 )> = Vec::new();
651
652 for (i, entry) in finalized_calls.into_iter().enumerate() {
653 match entry {
654 FinalizedToolCallEntry::Immediate(f) => slots.push(Some(*f)),
655 FinalizedToolCallEntry::Future(f) => {
656 slots.push(None);
657 pending_futures.push((i, f));
658 }
659 }
660 }
661
662 if !pending_futures.is_empty() {
663 let mut active = futures::stream::FuturesUnordered::new();
667 for (i, f) in pending_futures {
668 active.push(async move { (i, f.await) });
669 }
670
671 let mut cancel_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
674 cancel_interval.tick().await; loop {
677 tokio::select! {
678 result = active.next() => {
679 match result {
680 Some((idx, finalized)) => {
681 slots[idx] = Some(finalized);
682 }
683 None => break, }
685 }
686 _ = cancel_interval.tick() => {
687 if loop_ref.is_cancelled() {
688 tracing::info!(
689 "[TOOL-EXEC-PARALLEL] Cancelled during parallel execution, waiting for {} pending futures",
690 active.len()
691 );
692 break;
695 }
696 }
697 }
698 }
699
700 while let Some(result) = active.next().now_or_never().flatten() {
702 slots[result.0] = Some(result.1);
703 }
704 }
705
706 let ordered_finalized_calls: Vec<FinalizedToolCall> = slots.into_iter().flatten().collect();
708
709 let mut tool_result_messages = Vec::new();
710 for finalized in &ordered_finalized_calls {
711 let tool_result_message = create_tool_result_message(finalized);
712 let msg = Message::ToolResult(tool_result_message.clone());
713 emit(AgentEvent::MessageStart {
714 message: msg.clone(),
715 });
716 emit(AgentEvent::MessageEnd { message: msg });
717 tool_result_messages.push(tool_result_message);
718 }
719
720 Ok(ExecutedToolCallBatch {
721 messages: tool_result_messages,
722 terminate: should_terminate_batch(&ordered_finalized_calls),
723 })
724}
725
726pub(crate) async fn execute_prepared_tool_call_static(
727 tool_call: ToolCall,
728 tool: Option<Arc<dyn crate::tools::AgentTool>>,
729 args: serde_json::Value,
730 after_hook: Option<AfterToolCallHook>,
731 emit: Arc<dyn Fn(AgentEvent) + Send + Sync>,
732 ctx: &ToolExecContext,
733 cancel_notify: Option<Arc<Notify>>,
734) -> ExecutedToolCallOutcome {
735 let tool_call_id = tool_call.id.clone();
736 let tool_name = tool_call.name.clone();
737 let static_intent = tool.as_ref().and_then(|t| Some(t.intent()?.to_string()));
738
739 let mut result = AgentToolResult::success("");
740 let mut is_error = false;
741
742 if let Some(ref tool) = tool {
743 let context = infer_context(&tool_name, &args);
745
746 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
748 Arc::new(parking_lot::Mutex::new(context));
749
750 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
752 Arc::new(parking_lot::Mutex::new(None));
753 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
754
755 let emit_for_cb = emit.clone();
757 let tcid = tool_call_id.clone();
758 let tn = tool_name.clone();
759 let cc = Arc::clone(&context_cell);
760 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
761 let tab_id = *tab_id_slot.lock();
762 let ctx = cc.lock().clone();
763 emit_for_cb(AgentEvent::ToolExecutionUpdate {
764 tool_call_id: tcid.clone(),
765 tool_name: tn.clone(),
766 partial_result: msg,
767 tab_id,
768 context: ctx,
769 });
770 });
771 tool.on_progress(progress_callback(move |msg: String| {
772 progress_cb(msg);
773 }));
774
775 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
777 let exec_fut = tool.execute(&tool_call_id, args, None, ctx);
781 tokio::pin!(exec_fut);
782 let exec_result: Result<AgentToolResult, String> = match cancel_notify {
783 Some(notify) => {
784 let notify_for_select = Arc::clone(¬ify);
785 tokio::select! {
786 r = &mut exec_fut => r,
787 _ = notify_for_select.notified() => Err(format!(
788 "tool '{}' cancelled by agent loop",
789 tool_call_id
790 )),
791 }
792 }
793 None => exec_fut.await,
794 };
795 match exec_result {
796 Ok(r) => result = r,
797 Err(e) => {
798 result = AgentToolResult::error(e);
799 is_error = true;
800 }
801 }
802
803 enrich_context_from_metadata(&context_cell, &result);
804 }
805
806 if let Some(ref hook) = after_hook
807 && let Some(modified) = hook(&tool_call.name, &result).await.ok().flatten()
808 {
809 if let Some(ref details) = modified.metadata {
810 tracing::debug!(
811 tool = %tool_call.name,
812 details = %details,
813 "after_tool_call hook returned details"
814 );
815 }
816 result = modified;
817 is_error = !result.success;
818 }
819
820 let end_intent = result.intent.clone().or(static_intent);
821
822 emit(AgentEvent::ToolExecutionEnd {
823 tool_call_id: tool_call_id.clone(),
824 tool_name: tool_name.clone(),
825 intent: end_intent,
826 result: oxi_ai::ToolResult {
827 tool_call_id,
828 content: result.output.clone(),
829 status: if is_error {
830 String::from("error")
831 } else {
832 String::from("success")
833 },
834 },
835 is_error,
836 });
837
838 ExecutedToolCallOutcome { result, is_error }
839}
840
841async fn prepare_tool_call(
842 loop_ref: &super::AgentLoop,
843 tool_call: &ToolCall,
844) -> PreparedToolCallOutcome {
845 let tool = match loop_ref.tools.get(&tool_call.name) {
846 Some(t) => t,
847 None => {
848 return PreparedToolCallOutcome {
849 _kind: PreparedToolCallKind::Immediate,
850 immediate_result: Some(AgentToolResult::error(format!(
851 "Tool '{}' not found",
852 tool_call.name
853 ))),
854 is_error: true,
855 tool: None,
856 tool_call: tool_call.clone(),
857 args: tool_call.arguments.clone(),
858 };
859 }
860 };
861
862 let validated_args = tool_call.arguments.clone();
863
864 if let Some(ref hook) = loop_ref.before_tool_call
865 && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
866 {
867 return PreparedToolCallOutcome {
868 _kind: PreparedToolCallKind::Immediate,
869 immediate_result: Some(blocked),
870 is_error: true,
871 tool: None,
872 tool_call: tool_call.clone(),
873 args: validated_args,
874 };
875 }
876
877 PreparedToolCallOutcome {
878 _kind: PreparedToolCallKind::Prepared,
879 immediate_result: None,
880 is_error: false,
881 tool: Some(Arc::clone(&tool)),
882 tool_call: tool_call.clone(),
883 args: validated_args,
884 }
885}
886
887async fn execute_prepared_tool_call(
888 loop_ref: &super::AgentLoop,
889 prepared: &PreparedToolCallOutcome,
890 emit: &super::EmitFn,
891 ctx: &ToolExecContext,
892) -> ExecutedToolCallOutcome {
893 let tool_call_id = prepared.tool_call.id.clone();
894 let tool_name = prepared.tool_call.name.clone();
895
896 let mut result = AgentToolResult::success("");
897 let mut is_error = false;
898
899 if let Some(ref tool) = prepared.tool {
900 let tool_call_id_clone = tool_call_id.clone();
901 let tool_name_clone = tool_name.clone();
902 let emit_clone = emit.clone();
903
904 let context = infer_context(&tool_name, &prepared.args);
906
907 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
910 Arc::new(parking_lot::Mutex::new(context));
911
912 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
916 Arc::new(parking_lot::Mutex::new(None));
917
918 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
920
921 let tab_id_slot_cb = Arc::clone(&tab_id_slot);
923 let cc = Arc::clone(&context_cell);
924 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
925 let tab_id = *tab_id_slot_cb.lock();
926 let ctx = cc.lock().clone();
927 emit_clone(AgentEvent::ToolExecutionUpdate {
928 tool_call_id: tool_call_id_clone.clone(),
929 tool_name: tool_name_clone.clone(),
930 partial_result: msg,
931 tab_id,
932 context: ctx,
933 });
934 });
935
936 tool.on_progress(progress_callback(move |msg: String| {
938 progress_cb(msg);
939 }));
940
941 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
944
945 let cancel_notify = make_cancellation(loop_ref);
964 let cancel_for_select = Arc::clone(&cancel_notify);
965 let tool_call_id_for_exec = tool_call_id.clone();
966 let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
967 tokio::pin!(exec_fut);
968 let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
969 let cancelled_msg_for_select = cancelled_msg.clone();
970 let exec_result: Result<AgentToolResult, String> = tokio::select! {
971 r = &mut exec_fut => r,
972 _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
973 };
974 match exec_result {
975 Ok(r) => result = r,
976 Err(e) => {
977 result = AgentToolResult::error(e);
978 is_error = true;
979 }
980 }
981
982 enrich_context_from_metadata(&context_cell, &result);
983 }
984
985 ExecutedToolCallOutcome { result, is_error }
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991 use serde_json::json;
992
993 #[test]
994 fn infer_context_web_search() {
995 let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
996 assert!(matches!(
997 ctx,
998 Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
999 ));
1000 }
1001
1002 #[test]
1003 fn infer_context_web_search_with_engine() {
1004 let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
1005 assert!(matches!(
1006 ctx,
1007 Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
1008 ));
1009 }
1010
1011 #[test]
1012 fn infer_context_browse() {
1013 let ctx = infer_context(
1014 "browse",
1015 &json!({ "url": "https://github.com/example/repo" }),
1016 );
1017 match ctx {
1018 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1019 assert_eq!(url, "https://github.com/example/repo");
1020 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1021 }
1022 other => panic!("expected PageVisit, got {:?}", other),
1023 }
1024 }
1025
1026 #[test]
1027 fn infer_context_browse_extract() {
1028 let ctx = infer_context(
1029 "browse_extract",
1030 &json!({ "url": "https://example.com", "selector": ".title" }),
1031 );
1032 match ctx {
1033 Some(ToolCallContext::DataExtraction { target, url, .. }) => {
1034 assert_eq!(target, ".title");
1035 assert_eq!(url.as_deref(), Some("https://example.com"));
1036 }
1037 other => panic!("expected DataExtraction, got {:?}", other),
1038 }
1039 }
1040
1041 #[test]
1042 fn infer_context_browse_session_goto() {
1043 let ctx = infer_context(
1044 "browse_session",
1045 &json!({ "action": "goto", "url": "https://example.com" }),
1046 );
1047 match ctx {
1048 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1049 assert_eq!(url, "https://example.com");
1050 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1051 }
1052 other => panic!("expected PageVisit, got {:?}", other),
1053 }
1054 }
1055
1056 #[test]
1057 fn infer_context_browse_session_click() {
1058 let ctx = infer_context(
1059 "browse_session",
1060 &json!({ "action": "click", "selector": "#btn" }),
1061 );
1062 match ctx {
1063 Some(ToolCallContext::SessionAction { action, url }) => {
1064 assert_eq!(action, "click");
1065 assert!(url.is_none());
1066 }
1067 other => panic!("expected SessionAction, got {:?}", other),
1068 }
1069 }
1070
1071 #[test]
1072 fn infer_context_browse_script_with_steps_array() {
1073 let ctx = infer_context(
1074 "browse_script",
1075 &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
1076 );
1077 match ctx {
1078 Some(ToolCallContext::ScriptStep {
1079 current,
1080 total,
1081 step,
1082 }) => {
1083 assert_eq!(current, 0);
1084 assert_eq!(total, 2);
1085 assert_eq!(step, "starting");
1086 }
1087 other => panic!("expected ScriptStep, got {:?}", other),
1088 }
1089 }
1090
1091 #[test]
1092 fn infer_context_browse_script_empty() {
1093 let ctx = infer_context("browse_script", &json!({ "script": "" }));
1094 #[cfg(feature = "native-browser")]
1095 assert!(ctx.is_none());
1096 #[cfg(not(feature = "native-browser"))]
1097 assert!(ctx.is_none());
1098 }
1099
1100 #[test]
1101 fn infer_context_unknown_tool() {
1102 let ctx = infer_context("bash", &json!({ "command": "ls" }));
1103 assert!(ctx.is_none());
1104 }
1105
1106 #[test]
1107 fn infer_context_missing_args() {
1108 let ctx = infer_context("browse", &json!({}));
1110 assert!(ctx.is_none());
1111
1112 let ctx = infer_context("web_search", &json!({}));
1114 assert!(ctx.is_none());
1115 }
1116
1117 #[test]
1118 fn tool_context_serde_roundtrip() {
1119 let contexts = vec![
1120 ToolCallContext::WebSearch {
1121 query: "test".into(),
1122 engine: Some("ddg".into()),
1123 },
1124 ToolCallContext::PageVisit {
1125 url: "https://example.com".into(),
1126 reason: Some(VisitReason::DirectNavigation),
1127 page_title: None,
1128 page_status: None,
1129 page_bytes: None,
1130 page_duration_ms: None,
1131 navigation_error: None,
1132 screenshot: None,
1133 },
1134 ToolCallContext::PageVisit {
1135 url: "https://example.com".into(),
1136 reason: Some(VisitReason::SearchResult { position: 3 }),
1137 page_title: None,
1138 page_status: None,
1139 page_bytes: None,
1140 page_duration_ms: None,
1141 navigation_error: None,
1142 screenshot: None,
1143 },
1144 ToolCallContext::PageVisit {
1145 url: "https://example.com".into(),
1146 reason: None,
1147 page_title: Some("Example Page".into()),
1148 page_status: Some(200),
1149 page_bytes: Some(12400),
1150 page_duration_ms: Some(245),
1151 navigation_error: None,
1152 screenshot: None,
1153 },
1154 ToolCallContext::DataExtraction {
1155 target: ".title".into(),
1156 url: Some("https://example.com".into()),
1157 result_count: None,
1158 page_status: None,
1159 page_duration_ms: None,
1160 },
1161 ToolCallContext::DataExtraction {
1162 target: ".items".into(),
1163 url: Some("https://shop.example.com/products".into()),
1164 result_count: Some(42),
1165 page_status: Some(200),
1166 page_duration_ms: Some(180),
1167 },
1168 ToolCallContext::SessionAction {
1169 action: "goto".into(),
1170 url: Some("https://example.com".into()),
1171 },
1172 ToolCallContext::ScriptStep {
1173 current: 3,
1174 total: 10,
1175 step: "clicking".into(),
1176 },
1177 ];
1178
1179 for ctx in &contexts {
1180 let json = serde_json::to_string(ctx).unwrap();
1181 let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1182 let json2 = serde_json::to_string(&restored).unwrap();
1183 assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1184 }
1185 }
1186
1187 #[test]
1188 fn tool_execution_update_backward_compat() {
1189 let old_json = json!({
1191 "type": "toolExecutionUpdate",
1192 "tool_call_id": "call_123",
1193 "tool_name": "browse",
1194 "partial_result": "Loading...",
1195 "tab_id": null
1196 });
1197 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1198 match event {
1199 crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1200 assert!(context.is_none());
1201 }
1202 other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1203 }
1204 }
1205
1206 #[test]
1207 fn tool_execution_start_backward_compat() {
1208 let old_json = json!({
1210 "type": "toolExecutionStart",
1211 "tool_call_id": "call_123",
1212 "tool_name": "browse",
1213 "args": { "url": "https://example.com" }
1214 });
1215 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1216 match event {
1217 crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1218 assert!(context.is_none());
1219 }
1220 other => panic!("expected ToolExecutionStart, got {:?}", other),
1221 }
1222 }
1223
1224 #[test]
1225 fn browse_enrichment_callback_fills_page_visit() {
1226 use crate::tools::browse::BrowseProgress;
1227 use std::sync::Arc;
1228
1229 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1230 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1231 url: "https://example.com".into(),
1232 reason: Some(VisitReason::DirectNavigation),
1233 page_title: None,
1234 page_status: None,
1235 page_bytes: None,
1236 page_duration_ms: None,
1237 navigation_error: None,
1238 screenshot: None,
1239 })));
1240 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1241 cb(BrowseProgress::DocumentReady {
1242 url: "https://example.com/final".into(),
1243 title: "Example".into(),
1244 status: 200,
1245 bytes: 4096,
1246 duration_ms: 245,
1247 });
1248 let snapshot = cell.lock().clone();
1249 match snapshot {
1250 Some(ToolCallContext::PageVisit {
1251 url,
1252 page_title,
1253 page_status,
1254 page_bytes,
1255 page_duration_ms,
1256 ..
1257 }) => {
1258 assert_eq!(url, "https://example.com/final");
1259 assert_eq!(page_title.as_deref(), Some("Example"));
1260 assert_eq!(page_status, Some(200));
1261 assert_eq!(page_bytes, Some(4096));
1262 assert_eq!(page_duration_ms, Some(245));
1263 }
1264 other => panic!("expected PageVisit, got {:?}", other),
1265 }
1266 }
1267
1268 #[test]
1269 fn browse_enrichment_callback_fills_data_extraction() {
1270 use crate::tools::browse::BrowseProgress;
1271 use std::sync::Arc;
1272
1273 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1274 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1275 target: ".item".into(),
1276 url: Some("https://shop.example.com".into()),
1277 result_count: None,
1278 page_status: None,
1279 page_duration_ms: None,
1280 })),
1281 );
1282 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1283 cb(BrowseProgress::DocumentReady {
1284 url: "https://shop.example.com".into(),
1285 title: "Shop".into(),
1286 status: 200,
1287 bytes: 8192,
1288 duration_ms: 180,
1289 });
1290 let snapshot = cell.lock().clone();
1291 match snapshot {
1292 Some(ToolCallContext::DataExtraction {
1293 page_status,
1294 page_duration_ms,
1295 ..
1296 }) => {
1297 assert_eq!(page_status, Some(200));
1298 assert_eq!(page_duration_ms, Some(180));
1299 }
1300 other => panic!("expected DataExtraction, got {:?}", other),
1301 }
1302 }
1303
1304 #[test]
1305 fn browse_enrichment_callback_no_op_for_mismatched() {
1306 use crate::tools::browse::BrowseProgress;
1307 use std::sync::Arc;
1308
1309 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1311 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1312 current: 1,
1313 total: 5,
1314 step: "click".into(),
1315 })));
1316 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1317 cb(BrowseProgress::DocumentReady {
1318 url: "x".into(),
1319 title: "t".into(),
1320 status: 200,
1321 bytes: 0,
1322 duration_ms: 0,
1323 });
1324 assert!(matches!(
1326 cell.lock().as_ref(),
1327 Some(ToolCallContext::ScriptStep { .. })
1328 ));
1329 }
1330
1331 #[test]
1332 fn browse_enrichment_callback_fills_navigation_error() {
1333 use crate::tools::browse::BrowseProgress;
1334 use std::sync::Arc;
1335
1336 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1337 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1338 url: "https://example.com".into(),
1339 reason: Some(VisitReason::DirectNavigation),
1340 page_title: None,
1341 page_status: None,
1342 page_bytes: None,
1343 page_duration_ms: None,
1344 navigation_error: None,
1345 screenshot: None,
1346 })));
1347 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1348 cb(BrowseProgress::NavigationFailed {
1349 url: "https://example.com".into(),
1350 error: "connection refused".into(),
1351 });
1352 let snapshot = cell.lock().clone();
1353 match snapshot {
1354 Some(ToolCallContext::PageVisit {
1355 navigation_error, ..
1356 }) => {
1357 assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1358 }
1359 other => panic!("expected PageVisit, got {:?}", other),
1360 }
1361 }
1362
1363 #[test]
1364 fn browse_enrichment_callback_fills_screenshot() {
1365 use crate::tools::browse::BrowseProgress;
1366 use std::sync::Arc;
1367
1368 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1369 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1370 url: "https://example.com".into(),
1371 reason: Some(VisitReason::DirectNavigation),
1372 page_title: None,
1373 page_status: None,
1374 page_bytes: None,
1375 page_duration_ms: None,
1376 navigation_error: None,
1377 screenshot: None,
1378 })));
1379 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1380 cb(BrowseProgress::ScreenshotCaptured {
1381 bytes: 2048,
1382 width: 800,
1383 duration_ms: 120,
1384 });
1385 let snapshot = cell.lock().clone();
1386 match snapshot {
1387 Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1388 let meta = screenshot.expect("screenshot should be set");
1389 assert_eq!(meta.bytes, 2048);
1390 assert_eq!(meta.width, 800);
1391 assert_eq!(meta.duration_ms, 120);
1392 }
1393 other => panic!("expected PageVisit, got {:?}", other),
1394 }
1395 }
1396
1397 #[test]
1398 fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1399 use crate::tools::browse::BrowseProgress;
1400 use std::sync::Arc;
1401
1402 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1404 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1405 target: ".title".into(),
1406 url: None,
1407 result_count: None,
1408 page_status: None,
1409 page_duration_ms: None,
1410 })),
1411 );
1412 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1413 cb(BrowseProgress::NavigationFailed {
1414 url: "https://example.com".into(),
1415 error: "timeout".into(),
1416 });
1417 assert!(matches!(
1418 cell.lock().as_ref(),
1419 Some(ToolCallContext::DataExtraction { .. })
1420 ));
1421 }
1422}