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
820 if let Some(hook) = &after_hook {
821 match hook(&tool_call.name, &result).await {
822 Ok(Some(modified)) => {
823 if let Some(details) = &modified.metadata {
824 tracing::debug!(
825 tool = %tool_call.name,
826 details = %details,
827 "after_tool_call hook returned details"
828 );
829 }
830 result = modified;
831 is_error = !result.success;
832 }
833 Ok(None) => {}
834 Err(hook_err) => {
835 tracing::warn!(
836 tool = %tool_call.name,
837 error = %hook_err,
838 "after_tool_call hook failed, creating error result"
839 );
840 result = AgentToolResult::error(format!("after_tool_call hook failed: {hook_err}"));
841 is_error = true;
842 }
843 }
844 }
845
846 let end_intent = result.intent.clone().or(static_intent);
847
848 emit(AgentEvent::ToolExecutionEnd {
849 tool_call_id: tool_call_id.clone(),
850 tool_name: tool_name.clone(),
851 intent: end_intent,
852 result: oxicode_ai::ToolResult {
853 tool_call_id,
854 content: result.output.clone(),
855 status: if is_error {
856 String::from("error")
857 } else {
858 String::from("success")
859 },
860 },
861 is_error,
862 });
863
864 ExecutedToolCallOutcome { result, is_error }
865}
866
867async fn prepare_tool_call(
868 loop_ref: &super::AgentLoop,
869 tool_call: &ToolCall,
870) -> PreparedToolCallOutcome {
871 let tool = match loop_ref.tools.get(&tool_call.name) {
872 Some(t) => t,
873 None => {
874 return PreparedToolCallOutcome {
875 _kind: PreparedToolCallKind::Immediate,
876 immediate_result: Some(AgentToolResult::error(format!(
877 "Tool '{}' not found",
878 tool_call.name
879 ))),
880 is_error: true,
881 tool: None,
882 tool_call: tool_call.clone(),
883 args: tool_call.arguments.clone(),
884 };
885 }
886 };
887
888 let validated_args = tool_call.arguments.clone();
889
890 if let Some(ref hook) = loop_ref.before_tool_call
891 && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
892 {
893 return PreparedToolCallOutcome {
894 _kind: PreparedToolCallKind::Immediate,
895 immediate_result: Some(blocked),
896 is_error: true,
897 tool: None,
898 tool_call: tool_call.clone(),
899 args: validated_args,
900 };
901 }
902
903 PreparedToolCallOutcome {
904 _kind: PreparedToolCallKind::Prepared,
905 immediate_result: None,
906 is_error: false,
907 tool: Some(Arc::clone(&tool)),
908 tool_call: tool_call.clone(),
909 args: validated_args,
910 }
911}
912
913async fn execute_prepared_tool_call(
914 loop_ref: &super::AgentLoop,
915 prepared: &PreparedToolCallOutcome,
916 emit: &super::EmitFn,
917 ctx: &ToolExecContext,
918) -> ExecutedToolCallOutcome {
919 let tool_call_id = prepared.tool_call.id.clone();
920 let tool_name = prepared.tool_call.name.clone();
921
922 let mut result = AgentToolResult::success("");
923 let mut is_error = false;
924
925 if let Some(ref tool) = prepared.tool {
926 let tool_call_id_clone = tool_call_id.clone();
927 let tool_name_clone = tool_name.clone();
928 let emit_clone = emit.clone();
929
930 let context = infer_context(&tool_name, &prepared.args);
932
933 let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
936 Arc::new(parking_lot::Mutex::new(context));
937
938 let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
942 Arc::new(parking_lot::Mutex::new(None));
943
944 tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
946
947 let tab_id_slot_cb = Arc::clone(&tab_id_slot);
949 let cc = Arc::clone(&context_cell);
950 let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
951 let tab_id = *tab_id_slot_cb.lock();
952 let ctx = cc.lock().clone();
953 emit_clone(AgentEvent::ToolExecutionUpdate {
954 tool_call_id: tool_call_id_clone.clone(),
955 tool_name: tool_name_clone.clone(),
956 partial_result: msg,
957 tab_id,
958 context: ctx,
959 });
960 });
961
962 tool.on_progress(progress_callback(move |msg: String| {
964 progress_cb(msg);
965 }));
966
967 tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
970
971 let cancel_notify = make_cancellation(loop_ref);
990 let cancel_for_select = Arc::clone(&cancel_notify);
991 let tool_call_id_for_exec = tool_call_id.clone();
992 let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
993 tokio::pin!(exec_fut);
994 let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
995 let cancelled_msg_for_select = cancelled_msg.clone();
996 let exec_result: Result<AgentToolResult, String> = tokio::select! {
997 r = &mut exec_fut => r,
998 _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
999 };
1000 match exec_result {
1001 Ok(r) => result = r,
1002 Err(e) => {
1003 result = AgentToolResult::error(e);
1004 is_error = true;
1005 }
1006 }
1007
1008 enrich_context_from_metadata(&context_cell, &result);
1009 }
1010
1011 ExecutedToolCallOutcome { result, is_error }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use serde_json::json;
1018
1019 #[test]
1020 fn infer_context_web_search() {
1021 let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
1022 assert!(matches!(
1023 ctx,
1024 Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
1025 ));
1026 }
1027
1028 #[test]
1029 fn infer_context_web_search_with_engine() {
1030 let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
1031 assert!(matches!(
1032 ctx,
1033 Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
1034 ));
1035 }
1036
1037 #[test]
1038 fn infer_context_browse() {
1039 let ctx = infer_context(
1040 "browse",
1041 &json!({ "url": "https://github.com/example/repo" }),
1042 );
1043 match ctx {
1044 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1045 assert_eq!(url, "https://github.com/example/repo");
1046 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1047 }
1048 other => panic!("expected PageVisit, got {:?}", other),
1049 }
1050 }
1051
1052 #[test]
1053 fn infer_context_browse_extract() {
1054 let ctx = infer_context(
1055 "browse_extract",
1056 &json!({ "url": "https://example.com", "selector": ".title" }),
1057 );
1058 match ctx {
1059 Some(ToolCallContext::DataExtraction { target, url, .. }) => {
1060 assert_eq!(target, ".title");
1061 assert_eq!(url.as_deref(), Some("https://example.com"));
1062 }
1063 other => panic!("expected DataExtraction, got {:?}", other),
1064 }
1065 }
1066
1067 #[test]
1068 fn infer_context_browse_session_goto() {
1069 let ctx = infer_context(
1070 "browse_session",
1071 &json!({ "action": "goto", "url": "https://example.com" }),
1072 );
1073 match ctx {
1074 Some(ToolCallContext::PageVisit { url, reason, .. }) => {
1075 assert_eq!(url, "https://example.com");
1076 assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
1077 }
1078 other => panic!("expected PageVisit, got {:?}", other),
1079 }
1080 }
1081
1082 #[test]
1083 fn infer_context_browse_session_click() {
1084 let ctx = infer_context(
1085 "browse_session",
1086 &json!({ "action": "click", "selector": "#btn" }),
1087 );
1088 match ctx {
1089 Some(ToolCallContext::SessionAction { action, url }) => {
1090 assert_eq!(action, "click");
1091 assert!(url.is_none());
1092 }
1093 other => panic!("expected SessionAction, got {:?}", other),
1094 }
1095 }
1096
1097 #[test]
1098 fn infer_context_browse_script_with_steps_array() {
1099 let ctx = infer_context(
1100 "browse_script",
1101 &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
1102 );
1103 match ctx {
1104 Some(ToolCallContext::ScriptStep {
1105 current,
1106 total,
1107 step,
1108 }) => {
1109 assert_eq!(current, 0);
1110 assert_eq!(total, 2);
1111 assert_eq!(step, "starting");
1112 }
1113 other => panic!("expected ScriptStep, got {:?}", other),
1114 }
1115 }
1116
1117 #[test]
1118 fn infer_context_browse_script_empty() {
1119 let ctx = infer_context("browse_script", &json!({ "script": "" }));
1120 #[cfg(feature = "native-browser")]
1121 assert!(ctx.is_none());
1122 #[cfg(not(feature = "native-browser"))]
1123 assert!(ctx.is_none());
1124 }
1125
1126 #[test]
1127 fn infer_context_unknown_tool() {
1128 let ctx = infer_context("bash", &json!({ "command": "ls" }));
1129 assert!(ctx.is_none());
1130 }
1131
1132 #[test]
1133 fn infer_context_missing_args() {
1134 let ctx = infer_context("browse", &json!({}));
1136 assert!(ctx.is_none());
1137
1138 let ctx = infer_context("web_search", &json!({}));
1140 assert!(ctx.is_none());
1141 }
1142
1143 #[test]
1144 fn tool_context_serde_roundtrip() {
1145 let contexts = vec![
1146 ToolCallContext::WebSearch {
1147 query: "test".into(),
1148 engine: Some("ddg".into()),
1149 },
1150 ToolCallContext::PageVisit {
1151 url: "https://example.com".into(),
1152 reason: Some(VisitReason::DirectNavigation),
1153 page_title: None,
1154 page_status: None,
1155 page_bytes: None,
1156 page_duration_ms: None,
1157 navigation_error: None,
1158 screenshot: None,
1159 },
1160 ToolCallContext::PageVisit {
1161 url: "https://example.com".into(),
1162 reason: Some(VisitReason::SearchResult { position: 3 }),
1163 page_title: None,
1164 page_status: None,
1165 page_bytes: None,
1166 page_duration_ms: None,
1167 navigation_error: None,
1168 screenshot: None,
1169 },
1170 ToolCallContext::PageVisit {
1171 url: "https://example.com".into(),
1172 reason: None,
1173 page_title: Some("Example Page".into()),
1174 page_status: Some(200),
1175 page_bytes: Some(12400),
1176 page_duration_ms: Some(245),
1177 navigation_error: None,
1178 screenshot: None,
1179 },
1180 ToolCallContext::DataExtraction {
1181 target: ".title".into(),
1182 url: Some("https://example.com".into()),
1183 result_count: None,
1184 page_status: None,
1185 page_duration_ms: None,
1186 },
1187 ToolCallContext::DataExtraction {
1188 target: ".items".into(),
1189 url: Some("https://shop.example.com/products".into()),
1190 result_count: Some(42),
1191 page_status: Some(200),
1192 page_duration_ms: Some(180),
1193 },
1194 ToolCallContext::SessionAction {
1195 action: "goto".into(),
1196 url: Some("https://example.com".into()),
1197 },
1198 ToolCallContext::ScriptStep {
1199 current: 3,
1200 total: 10,
1201 step: "clicking".into(),
1202 },
1203 ];
1204
1205 for ctx in &contexts {
1206 let json = serde_json::to_string(ctx).unwrap();
1207 let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1208 let json2 = serde_json::to_string(&restored).unwrap();
1209 assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1210 }
1211 }
1212
1213 #[test]
1214 fn tool_execution_update_backward_compat() {
1215 let old_json = json!({
1217 "type": "toolExecutionUpdate",
1218 "tool_call_id": "call_123",
1219 "tool_name": "browse",
1220 "partial_result": "Loading...",
1221 "tab_id": null
1222 });
1223 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1224 match event {
1225 crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1226 assert!(context.is_none());
1227 }
1228 other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1229 }
1230 }
1231
1232 #[test]
1233 fn tool_execution_start_backward_compat() {
1234 let old_json = json!({
1236 "type": "toolExecutionStart",
1237 "tool_call_id": "call_123",
1238 "tool_name": "browse",
1239 "args": { "url": "https://example.com" }
1240 });
1241 let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1242 match event {
1243 crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1244 assert!(context.is_none());
1245 }
1246 other => panic!("expected ToolExecutionStart, got {:?}", other),
1247 }
1248 }
1249
1250 #[test]
1251 fn browse_enrichment_callback_fills_page_visit() {
1252 use crate::tools::browse::BrowseProgress;
1253 use std::sync::Arc;
1254
1255 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1256 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1257 url: "https://example.com".into(),
1258 reason: Some(VisitReason::DirectNavigation),
1259 page_title: None,
1260 page_status: None,
1261 page_bytes: None,
1262 page_duration_ms: None,
1263 navigation_error: None,
1264 screenshot: None,
1265 })));
1266 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1267 cb(BrowseProgress::DocumentReady {
1268 url: "https://example.com/final".into(),
1269 title: "Example".into(),
1270 status: 200,
1271 bytes: 4096,
1272 duration_ms: 245,
1273 });
1274 let snapshot = cell.lock().clone();
1275 match snapshot {
1276 Some(ToolCallContext::PageVisit {
1277 url,
1278 page_title,
1279 page_status,
1280 page_bytes,
1281 page_duration_ms,
1282 ..
1283 }) => {
1284 assert_eq!(url, "https://example.com/final");
1285 assert_eq!(page_title.as_deref(), Some("Example"));
1286 assert_eq!(page_status, Some(200));
1287 assert_eq!(page_bytes, Some(4096));
1288 assert_eq!(page_duration_ms, Some(245));
1289 }
1290 other => panic!("expected PageVisit, got {:?}", other),
1291 }
1292 }
1293
1294 #[test]
1295 fn browse_enrichment_callback_fills_data_extraction() {
1296 use crate::tools::browse::BrowseProgress;
1297 use std::sync::Arc;
1298
1299 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1300 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1301 target: ".item".into(),
1302 url: Some("https://shop.example.com".into()),
1303 result_count: None,
1304 page_status: None,
1305 page_duration_ms: None,
1306 })),
1307 );
1308 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1309 cb(BrowseProgress::DocumentReady {
1310 url: "https://shop.example.com".into(),
1311 title: "Shop".into(),
1312 status: 200,
1313 bytes: 8192,
1314 duration_ms: 180,
1315 });
1316 let snapshot = cell.lock().clone();
1317 match snapshot {
1318 Some(ToolCallContext::DataExtraction {
1319 page_status,
1320 page_duration_ms,
1321 ..
1322 }) => {
1323 assert_eq!(page_status, Some(200));
1324 assert_eq!(page_duration_ms, Some(180));
1325 }
1326 other => panic!("expected DataExtraction, got {:?}", other),
1327 }
1328 }
1329
1330 #[test]
1331 fn browse_enrichment_callback_no_op_for_mismatched() {
1332 use crate::tools::browse::BrowseProgress;
1333 use std::sync::Arc;
1334
1335 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1337 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1338 current: 1,
1339 total: 5,
1340 step: "click".into(),
1341 })));
1342 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1343 cb(BrowseProgress::DocumentReady {
1344 url: "x".into(),
1345 title: "t".into(),
1346 status: 200,
1347 bytes: 0,
1348 duration_ms: 0,
1349 });
1350 assert!(matches!(
1352 cell.lock().as_ref(),
1353 Some(ToolCallContext::ScriptStep { .. })
1354 ));
1355 }
1356
1357 #[test]
1358 fn browse_enrichment_callback_fills_navigation_error() {
1359 use crate::tools::browse::BrowseProgress;
1360 use std::sync::Arc;
1361
1362 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1363 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1364 url: "https://example.com".into(),
1365 reason: Some(VisitReason::DirectNavigation),
1366 page_title: None,
1367 page_status: None,
1368 page_bytes: None,
1369 page_duration_ms: None,
1370 navigation_error: None,
1371 screenshot: None,
1372 })));
1373 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1374 cb(BrowseProgress::NavigationFailed {
1375 url: "https://example.com".into(),
1376 error: "connection refused".into(),
1377 });
1378 let snapshot = cell.lock().clone();
1379 match snapshot {
1380 Some(ToolCallContext::PageVisit {
1381 navigation_error, ..
1382 }) => {
1383 assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1384 }
1385 other => panic!("expected PageVisit, got {:?}", other),
1386 }
1387 }
1388
1389 #[test]
1390 fn browse_enrichment_callback_fills_screenshot() {
1391 use crate::tools::browse::BrowseProgress;
1392 use std::sync::Arc;
1393
1394 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1395 Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1396 url: "https://example.com".into(),
1397 reason: Some(VisitReason::DirectNavigation),
1398 page_title: None,
1399 page_status: None,
1400 page_bytes: None,
1401 page_duration_ms: None,
1402 navigation_error: None,
1403 screenshot: None,
1404 })));
1405 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1406 cb(BrowseProgress::ScreenshotCaptured {
1407 bytes: 2048,
1408 width: 800,
1409 duration_ms: 120,
1410 });
1411 let snapshot = cell.lock().clone();
1412 match snapshot {
1413 Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1414 let meta = screenshot.expect("screenshot should be set");
1415 assert_eq!(meta.bytes, 2048);
1416 assert_eq!(meta.width, 800);
1417 assert_eq!(meta.duration_ms, 120);
1418 }
1419 other => panic!("expected PageVisit, got {:?}", other),
1420 }
1421 }
1422
1423 #[test]
1424 fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1425 use crate::tools::browse::BrowseProgress;
1426 use std::sync::Arc;
1427
1428 let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1430 parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1431 target: ".title".into(),
1432 url: None,
1433 result_count: None,
1434 page_status: None,
1435 page_duration_ms: None,
1436 })),
1437 );
1438 let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1439 cb(BrowseProgress::NavigationFailed {
1440 url: "https://example.com".into(),
1441 error: "timeout".into(),
1442 });
1443 assert!(matches!(
1444 cell.lock().as_ref(),
1445 Some(ToolCallContext::DataExtraction { .. })
1446 ));
1447 }
1448}