Skip to main content

oxi_agent/agent_loop/
tool_exec.rs

1/// Tool execution logic for agent loop
2use 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
11/// Build a cancellation [`Notify`] handle for a single tool call, plus a
12/// background tokio task that fires `notify_one()` whenever `cancel_signal`
13/// transitions to `true`. Tools that opt into cancellation await the
14/// returned `Notify` inside a `tokio::select!` against their main work;
15/// when the loop's `cancel_signal` flips, the task wakes the tool within
16/// ~250 ms (the poll cadence) without requiring the tool to know about
17/// the loop's `AtomicBool`.
18///
19/// Why `Notify` instead of `oneshot::Sender`: the `AgentTool` trait already
20/// exposes a `signal: Option<oneshot::Receiver<()>>` parameter, but every
21/// call site was passing `None` (audit finding F-8), defeating the contract.
22/// This helper re-establishes the contract with a primitive that survives
23/// the closure-move semantics of `tokio::spawn` (`oneshot::Sender` cannot
24/// be both moved into the spawned task AND returned to the caller).
25///
26/// `loop_ref` supplies the loop's `cancel_signal` (`Option<Arc<AtomicBool>>`).
27/// When `None` (e.g. tests that don't install a cancel flag) the returned
28/// `Notify` is never fired; the tool simply awaits it until the call ends.
29/// The detached poll task self-terminates when `notify_one` fires.
30fn 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(&notify);
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
51// ── Context inference ─────────────────────────────────────────────────────
52
53/// Infer semantic context from tool name and arguments.
54///
55/// This is the **single point** where the agent loop assigns meaning
56/// to tool calls. Tools themselves remain unaware of semantics — they
57/// just do their work and emit facts.
58fn 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            // "goto" is semantically a page visit, not a generic action.
87            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                // No structured steps array — script is YAML text.
116                // Parsing requires serde_yaml (native-browser feature).
117                #[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
141/// Enrich `context_cell` from `AgentToolResult` metadata after execute.
142/// Handles `result_count` for `DataExtraction` contexts.
143fn 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
157/// Build a `BrowseProgressCallback` that enriches `context_cell` with
158/// structured data from each `BrowseProgress` event.
159///
160/// Mapping:
161/// - `DocumentReady` + `PageVisit` → fills `page_title`, `page_status`,
162///   `page_bytes`, `page_duration_ms`; updates `url` if redirected.
163/// - `DocumentReady` + `DataExtraction` → fills `page_status`, `page_duration_ms`.
164/// - `NavigationFailed` + `PageVisit` → fills `navigation_error`.
165/// - `ScreenshotCaptured` + `PageVisit` → fills `screenshot`.
166/// - All other combinations → no-op.
167fn 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                // Update URL if redirected.
191                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            // ── NavigationFailed → PageVisit.navigation_error ──
216            (
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            // ── ScreenshotCaptured → PageVisit.screenshot ──
226            (
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(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
275pub(crate) async fn execute_tool_calls(
276    loop_ref: &super::AgentLoop,
277    messages: &mut Vec<Message>,
278    assistant_message: &AssistantMessage,
279    tool_calls: Vec<ToolCall>,
280    emit: &super::EmitFn,
281    ctx: &ToolExecContext,
282) -> Result<ExecutedToolCallBatch> {
283    if loop_ref.config.tool_execution == ToolExecutionMode::Sequential {
284        execute_tool_calls_sequential(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
285            .await
286    } else {
287        execute_tool_calls_parallel(loop_ref, messages, assistant_message, tool_calls, emit, ctx)
288            .await
289    }
290}
291
292async fn execute_tool_calls_sequential(
293    loop_ref: &super::AgentLoop,
294    _messages: &mut Vec<Message>,
295    _assistant_message: &AssistantMessage,
296    tool_calls: Vec<ToolCall>,
297    emit: &super::EmitFn,
298    ctx: &ToolExecContext,
299) -> Result<ExecutedToolCallBatch> {
300    let mut finalized_calls = Vec::new();
301    let mut tool_result_messages = Vec::new();
302
303    for tool_call in tool_calls {
304        // Check cancellation before executing each tool.
305        // This allows Ctrl+C to interrupt a batch of tool calls
306        // without waiting for all of them to complete.
307        if loop_ref.is_cancelled() {
308            tracing::info!(
309                "[TOOL-EXEC] Cancelled before executing tool {}",
310                tool_call.name
311            );
312            break;
313        }
314        // Clone tool_call fields once upfront to avoid repeated clones.
315        let tc_id = tool_call.id.clone();
316        let tc_name = tool_call.name.clone();
317        let tc_args = tool_call.arguments.clone();
318
319        emit(AgentEvent::ToolExecutionStart {
320            tool_call_id: tc_id.clone(),
321            tool_name: tc_name.clone(),
322            args: tc_args.clone(),
323            context: infer_context(&tc_name, &tc_args),
324        });
325
326        let prepared = prepare_tool_call(loop_ref, &tool_call).await;
327
328        let finalized = if let Some(result) = prepared.immediate_result {
329            FinalizedToolCall {
330                tool_call,
331                result,
332                is_error: prepared.is_error,
333            }
334        } else {
335            let executed = execute_prepared_tool_call(loop_ref, &prepared, emit, ctx).await;
336
337            let mut result = executed.result;
338            let mut is_error = executed.is_error;
339
340            if let Some(ref hook) = loop_ref.after_tool_call
341                && let Some(modified) = hook(&tc_name, &result).await.ok().flatten()
342            {
343                if let Some(ref details) = modified.metadata {
344                    tracing::debug!(
345                        tool = %tc_name,
346                        details = %details,
347                        "after_tool_call hook returned details"
348                    );
349                }
350                result = modified;
351                is_error = !result.success;
352            }
353
354            FinalizedToolCall {
355                tool_call,
356                result,
357                is_error,
358            }
359        };
360
361        emit(AgentEvent::ToolExecutionEnd {
362            tool_call_id: finalized.tool_call.id.clone(),
363            tool_name: finalized.tool_call.name.clone(),
364            result: oxi_ai::ToolResult {
365                tool_call_id: finalized.tool_call.id.clone(),
366                content: finalized.result.output.clone(),
367                status: if finalized.is_error {
368                    String::from("error")
369                } else {
370                    String::from("success")
371                },
372            },
373            is_error: finalized.is_error,
374        });
375
376        let tool_result_message = create_tool_result_message(&finalized);
377        let msg = Message::ToolResult(tool_result_message.clone());
378        emit(AgentEvent::MessageStart {
379            message: msg.clone(),
380        });
381        emit(AgentEvent::MessageEnd { message: msg });
382
383        finalized_calls.push(finalized);
384        tool_result_messages.push(tool_result_message);
385    }
386
387    Ok(ExecutedToolCallBatch {
388        messages: tool_result_messages,
389        terminate: should_terminate_batch(&finalized_calls),
390    })
391}
392
393async fn execute_tool_calls_parallel(
394    loop_ref: &super::AgentLoop,
395    _messages: &mut Vec<Message>,
396    _assistant_message: &AssistantMessage,
397    tool_calls: Vec<ToolCall>,
398    emit: &super::EmitFn,
399    ctx: &ToolExecContext,
400) -> Result<ExecutedToolCallBatch> {
401    let mut finalized_calls: Vec<FinalizedToolCallEntry> = Vec::new();
402
403    for tool_call in tool_calls {
404        // Check cancellation before preparing each tool.
405        if loop_ref.is_cancelled() {
406            tracing::info!(
407                "[TOOL-EXEC-PARALLEL] Cancelled before preparing tool {}",
408                tool_call.name
409            );
410            break;
411        }
412        // Clone tool_call fields once upfront to avoid repeated clones.
413        let tc_id = tool_call.id.clone();
414        let tc_name = tool_call.name.clone();
415        let tc_args = tool_call.arguments.clone();
416
417        emit(AgentEvent::ToolExecutionStart {
418            tool_call_id: tc_id.clone(),
419            tool_name: tc_name.clone(),
420            args: tc_args.clone(),
421            context: infer_context(&tc_name, &tc_args),
422        });
423
424        let prepared = prepare_tool_call(loop_ref, &tool_call).await;
425
426        if let Some(result) = prepared.immediate_result {
427            let finalized = FinalizedToolCall {
428                tool_call,
429                result,
430                is_error: prepared.is_error,
431            };
432
433            emit(AgentEvent::ToolExecutionEnd {
434                tool_call_id: finalized.tool_call.id.clone(),
435                tool_name: finalized.tool_call.name.clone(),
436                result: oxi_ai::ToolResult {
437                    tool_call_id: finalized.tool_call.id.clone(),
438                    content: finalized.result.output.clone(),
439                    status: if finalized.is_error {
440                        String::from("error")
441                    } else {
442                        String::from("success")
443                    },
444                },
445                is_error: finalized.is_error,
446            });
447
448            finalized_calls.push(FinalizedToolCallEntry::Immediate(finalized));
449        } else {
450            let tool = prepared.tool.clone();
451            let args = prepared.args.clone();
452            let after_hook = loop_ref.after_tool_call.clone();
453            let emit_clone = emit.clone();
454            let ctx_clone = ctx.clone();
455            // Pre-build the cancellation notify *outside* the async move
456            // closure so `loop_ref` does not need to be `Send + 'static`.
457            // The helper only borrows `loop_ref` for the duration of the
458            // synchronous body of `make_cancellation` (it returns
459            // `Arc<Notify>` plus a detached `tokio::spawn` that owns the
460            // flag clone, so by the time this scope ends `loop_ref` is
461            // no longer referenced).
462            let cancel_notify = make_cancellation(loop_ref);
463
464            finalized_calls.push(FinalizedToolCallEntry::Future(Box::pin(async move {
465                let executed = execute_prepared_tool_call_static(
466                    tool_call.clone(),
467                    tool,
468                    args,
469                    after_hook.clone(),
470                    emit_clone.clone(),
471                    &ctx_clone,
472                    Some(cancel_notify),
473                )
474                .await;
475
476                FinalizedToolCall {
477                    tool_call,
478                    result: executed.result,
479                    is_error: executed.is_error,
480                }
481            })));
482        }
483    }
484
485    let mut slots: Vec<Option<FinalizedToolCall>> = Vec::with_capacity(finalized_calls.len());
486    #[allow(clippy::type_complexity)]
487    let mut pending_futures: Vec<(
488        usize,
489        Pin<Box<dyn futures::Future<Output = FinalizedToolCall> + Send>>,
490    )> = Vec::new();
491
492    for (i, entry) in finalized_calls.into_iter().enumerate() {
493        match entry {
494            FinalizedToolCallEntry::Immediate(f) => slots.push(Some(f)),
495            FinalizedToolCallEntry::Future(f) => {
496                slots.push(None);
497                pending_futures.push((i, f));
498            }
499        }
500    }
501
502    if !pending_futures.is_empty() {
503        // Poll futures with periodic cancel checks.
504        // Uses `FuturesUnordered` so we can drain completed results as they
505        // arrive and detect cancellation without waiting for all futures.
506        let mut active = futures::stream::FuturesUnordered::new();
507        for (i, f) in pending_futures {
508            active.push(async move { (i, f.await) });
509        }
510
511        // Check cancel every 100ms so Ctrl+C is responsive even when
512        // tool calls are slow.
513        let mut cancel_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
514        cancel_interval.tick().await; // consume immediate first tick
515
516        loop {
517            tokio::select! {
518                result = active.next() => {
519                    match result {
520                        Some((idx, finalized)) => {
521                            slots[idx] = Some(finalized);
522                        }
523                        None => break, // all futures completed
524                    }
525                }
526                _ = cancel_interval.tick() => {
527                    if loop_ref.is_cancelled() {
528                        tracing::info!(
529                            "[TOOL-EXEC-PARALLEL] Cancelled during parallel execution, waiting for {} pending futures",
530                            active.len()
531                        );
532                        // Don't abort futures — let them finish (they may have
533                        // side effects). But skip waiting and return what we have.
534                        break;
535                    }
536                }
537            }
538        }
539
540        // Drain any remaining futures that completed before cancellation.
541        while let Some(result) = active.next().now_or_never().flatten() {
542            slots[result.0] = Some(result.1);
543        }
544    }
545
546    // Slots for futures that were still running at cancellation time remain None.
547    let ordered_finalized_calls: Vec<FinalizedToolCall> = slots.into_iter().flatten().collect();
548
549    let mut tool_result_messages = Vec::new();
550    for finalized in &ordered_finalized_calls {
551        let tool_result_message = create_tool_result_message(finalized);
552        let msg = Message::ToolResult(tool_result_message.clone());
553        emit(AgentEvent::MessageStart {
554            message: msg.clone(),
555        });
556        emit(AgentEvent::MessageEnd { message: msg });
557        tool_result_messages.push(tool_result_message);
558    }
559
560    Ok(ExecutedToolCallBatch {
561        messages: tool_result_messages,
562        terminate: should_terminate_batch(&ordered_finalized_calls),
563    })
564}
565
566pub(crate) async fn execute_prepared_tool_call_static(
567    tool_call: ToolCall,
568    tool: Option<Arc<dyn crate::tools::AgentTool>>,
569    args: serde_json::Value,
570    after_hook: Option<AfterToolCallHook>,
571    emit: Arc<dyn Fn(AgentEvent) + Send + Sync>,
572    ctx: &ToolExecContext,
573    cancel_notify: Option<Arc<Notify>>,
574) -> ExecutedToolCallOutcome {
575    let tool_call_id = tool_call.id.clone();
576    let tool_name = tool_call.name.clone();
577
578    let mut result = AgentToolResult::success("");
579    let mut is_error = false;
580
581    if let Some(ref tool) = tool {
582        // Infer semantic context — same as sequential path.
583        let context = infer_context(&tool_name, &args);
584
585        // Shared context cell for progressive enrichment.
586        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
587            Arc::new(parking_lot::Mutex::new(context));
588
589        // Tab ID slot.
590        let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
591            Arc::new(parking_lot::Mutex::new(None));
592        tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
593
594        // String progress callback.
595        let emit_for_cb = emit.clone();
596        let tcid = tool_call_id.clone();
597        let tn = tool_name.clone();
598        let cc = Arc::clone(&context_cell);
599        let progress_cb: Arc<dyn Fn(String) + Send + Sync> = Arc::new(move |msg: String| {
600            let tab_id = *tab_id_slot.lock();
601            let ctx = cc.lock().clone();
602            emit_for_cb(AgentEvent::ToolExecutionUpdate {
603                tool_call_id: tcid.clone(),
604                tool_name: tn.clone(),
605                partial_result: msg,
606                tab_id,
607                context: ctx,
608            });
609        });
610        tool.on_progress(progress_callback(move |msg: String| {
611            progress_cb(msg);
612        }));
613
614        // Browse progress callback — enriches context cell.
615        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
616        // F-8 (audit 2026-06-21): see the matching note in
617        // `execute_prepared_tool_call`. Same `select!`-based cancellation
618        // wrap, same trade-off — additive, no trait change.
619        let exec_fut = tool.execute(&tool_call_id, args, None, ctx);
620        tokio::pin!(exec_fut);
621        let exec_result: Result<AgentToolResult, String> = match cancel_notify {
622            Some(notify) => {
623                let notify_for_select = Arc::clone(&notify);
624                tokio::select! {
625                    r = &mut exec_fut => r,
626                    _ = notify_for_select.notified() => Err(format!(
627                        "tool '{}' cancelled by agent loop",
628                        tool_call_id
629                    )),
630                }
631            }
632            None => exec_fut.await,
633        };
634        match exec_result {
635            Ok(r) => result = r,
636            Err(e) => {
637                result = AgentToolResult::error(e);
638                is_error = true;
639            }
640        }
641
642        enrich_context_from_metadata(&context_cell, &result);
643    }
644
645    if let Some(ref hook) = after_hook
646        && let Some(modified) = hook(&tool_call.name, &result).await.ok().flatten()
647    {
648        if let Some(ref details) = modified.metadata {
649            tracing::debug!(
650                tool = %tool_call.name,
651                details = %details,
652                "after_tool_call hook returned details"
653            );
654        }
655        result = modified;
656        is_error = !result.success;
657    }
658
659    emit(AgentEvent::ToolExecutionEnd {
660        tool_call_id: tool_call_id.clone(),
661        tool_name: tool_name.clone(),
662        result: oxi_ai::ToolResult {
663            tool_call_id,
664            content: result.output.clone(),
665            status: if is_error {
666                String::from("error")
667            } else {
668                String::from("success")
669            },
670        },
671        is_error,
672    });
673
674    ExecutedToolCallOutcome { result, is_error }
675}
676
677async fn prepare_tool_call(
678    loop_ref: &super::AgentLoop,
679    tool_call: &ToolCall,
680) -> PreparedToolCallOutcome {
681    let tool = match loop_ref.tools.get(&tool_call.name) {
682        Some(t) => t,
683        None => {
684            return PreparedToolCallOutcome {
685                _kind: PreparedToolCallKind::Immediate,
686                immediate_result: Some(AgentToolResult::error(format!(
687                    "Tool '{}' not found",
688                    tool_call.name
689                ))),
690                is_error: true,
691                tool: None,
692                tool_call: tool_call.clone(),
693                args: tool_call.arguments.clone(),
694            };
695        }
696    };
697
698    let validated_args = tool_call.arguments.clone();
699
700    if let Some(ref hook) = loop_ref.before_tool_call
701        && let Some(blocked) = hook(&tool_call.name, &validated_args).await.ok().flatten()
702    {
703        return PreparedToolCallOutcome {
704            _kind: PreparedToolCallKind::Immediate,
705            immediate_result: Some(blocked),
706            is_error: true,
707            tool: None,
708            tool_call: tool_call.clone(),
709            args: validated_args,
710        };
711    }
712
713    PreparedToolCallOutcome {
714        _kind: PreparedToolCallKind::Prepared,
715        immediate_result: None,
716        is_error: false,
717        tool: Some(Arc::clone(&tool)),
718        tool_call: tool_call.clone(),
719        args: validated_args,
720    }
721}
722
723async fn execute_prepared_tool_call(
724    loop_ref: &super::AgentLoop,
725    prepared: &PreparedToolCallOutcome,
726    emit: &super::EmitFn,
727    ctx: &ToolExecContext,
728) -> ExecutedToolCallOutcome {
729    let tool_call_id = prepared.tool_call.id.clone();
730    let tool_name = prepared.tool_call.name.clone();
731
732    let mut result = AgentToolResult::success("");
733    let mut is_error = false;
734
735    if let Some(ref tool) = prepared.tool {
736        let tool_call_id_clone = tool_call_id.clone();
737        let tool_name_clone = tool_name.clone();
738        let emit_clone = emit.clone();
739
740        // Infer semantic context from tool name + args.
741        let context = infer_context(&tool_name, &prepared.args);
742
743        // Shared mutable context cell. The String progress callback reads
744        // from here; the browse progress callback writes enriched fields.
745        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
746            Arc::new(parking_lot::Mutex::new(context));
747
748        // Shared slot for the active tab ID. Tools that manage browser tabs
749        // (BrowseTool) populate this when they open a tab; the progress
750        // callback reads it to include `tab_id` in `ToolExecutionUpdate`.
751        let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
752            Arc::new(parking_lot::Mutex::new(None));
753
754        // Pass the slot to the tool so it can write the tab_id.
755        tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
756
757        // String progress callback — reads context from shared cell.
758        let tab_id_slot_cb = Arc::clone(&tab_id_slot);
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_cb.lock();
762            let ctx = cc.lock().clone();
763            emit_clone(AgentEvent::ToolExecutionUpdate {
764                tool_call_id: tool_call_id_clone.clone(),
765                tool_name: tool_name_clone.clone(),
766                partial_result: msg,
767                tab_id,
768                context: ctx,
769            });
770        });
771
772        // Wire up progress callback BEFORE execute — pi-mono: tool's onUpdate
773        tool.on_progress(progress_callback(move |msg: String| {
774            progress_cb(msg);
775        }));
776
777        // Browse progress callback — enriches context cell with structured data.
778        // Wire up browse progress callback.
779        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
780
781        // F-8 (audit 2026-06-21): wrap tool execution in a `tokio::select!`
782        // against the loop's cancellation notify. Before this, every call
783        // site passed `None` for the `signal: Option<oneshot::Receiver<()>>`
784        // parameter, defeating the cancellation contract — a long-running
785        // tool (e.g. `bash` with a 30s sleep) would only observe a cancel
786        // flag on the next 500 ms poll cycle, *after* it had already done
787        // most of its work. With `select!`, the tool's `await` is dropped
788        // the instant `cancel_signal` flips, returning control to the loop
789        // within ~250 ms (the poll cadence in `make_cancellation`).
790        //
791        // The tool's own `signal` argument is still `None` here — that
792        // parameter would require a trait signature change. `select!`
793        // cancellation at the call site is the minimal, additive fix
794        // that preserves the existing trait contract while honoring
795        // cancellation at the *outer* await point. Tools that respect
796        // `ctx.cancelled` (most do) still observe the loop's stop flag
797        // through `ctx`; tools that ignore it now at least get their
798        // outer future cancelled promptly.
799        let cancel_notify = make_cancellation(loop_ref);
800        let cancel_for_select = Arc::clone(&cancel_notify);
801        let tool_call_id_for_exec = tool_call_id.clone();
802        let exec_fut = tool.execute(&tool_call_id_for_exec, prepared.args.clone(), None, ctx);
803        tokio::pin!(exec_fut);
804        let cancelled_msg = format!("tool '{}' cancelled by agent loop", tool_call_id_for_exec);
805        let cancelled_msg_for_select = cancelled_msg.clone();
806        let exec_result: Result<AgentToolResult, String> = tokio::select! {
807            r = &mut exec_fut => r,
808            _ = cancel_for_select.notified() => Err(cancelled_msg_for_select),
809        };
810        match exec_result {
811            Ok(r) => result = r,
812            Err(e) => {
813                result = AgentToolResult::error(e);
814                is_error = true;
815            }
816        }
817
818        enrich_context_from_metadata(&context_cell, &result);
819    }
820
821    ExecutedToolCallOutcome { result, is_error }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827    use serde_json::json;
828
829    #[test]
830    fn infer_context_web_search() {
831        let ctx = infer_context("web_search", &json!({ "query": "rust headless browser" }));
832        assert!(matches!(
833            ctx,
834            Some(ToolCallContext::WebSearch { query, .. }) if query == "rust headless browser"
835        ));
836    }
837
838    #[test]
839    fn infer_context_web_search_with_engine() {
840        let ctx = infer_context("web_search", &json!({ "query": "rust", "engines": "bing" }));
841        assert!(matches!(
842            ctx,
843            Some(ToolCallContext::WebSearch { engine: Some(e), .. }) if e == "bing"
844        ));
845    }
846
847    #[test]
848    fn infer_context_browse() {
849        let ctx = infer_context(
850            "browse",
851            &json!({ "url": "https://github.com/example/repo" }),
852        );
853        match ctx {
854            Some(ToolCallContext::PageVisit { url, reason, .. }) => {
855                assert_eq!(url, "https://github.com/example/repo");
856                assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
857            }
858            other => panic!("expected PageVisit, got {:?}", other),
859        }
860    }
861
862    #[test]
863    fn infer_context_browse_extract() {
864        let ctx = infer_context(
865            "browse_extract",
866            &json!({ "url": "https://example.com", "selector": ".title" }),
867        );
868        match ctx {
869            Some(ToolCallContext::DataExtraction { target, url, .. }) => {
870                assert_eq!(target, ".title");
871                assert_eq!(url.as_deref(), Some("https://example.com"));
872            }
873            other => panic!("expected DataExtraction, got {:?}", other),
874        }
875    }
876
877    #[test]
878    fn infer_context_browse_session_goto() {
879        let ctx = infer_context(
880            "browse_session",
881            &json!({ "action": "goto", "url": "https://example.com" }),
882        );
883        match ctx {
884            Some(ToolCallContext::PageVisit { url, reason, .. }) => {
885                assert_eq!(url, "https://example.com");
886                assert!(matches!(reason, Some(VisitReason::DirectNavigation)));
887            }
888            other => panic!("expected PageVisit, got {:?}", other),
889        }
890    }
891
892    #[test]
893    fn infer_context_browse_session_click() {
894        let ctx = infer_context(
895            "browse_session",
896            &json!({ "action": "click", "selector": "#btn" }),
897        );
898        match ctx {
899            Some(ToolCallContext::SessionAction { action, url }) => {
900                assert_eq!(action, "click");
901                assert!(url.is_none());
902            }
903            other => panic!("expected SessionAction, got {:?}", other),
904        }
905    }
906
907    #[test]
908    fn infer_context_browse_script_with_steps_array() {
909        let ctx = infer_context(
910            "browse_script",
911            &json!({ "steps": [{"goto": "https://example.com"}, {"click": "#btn"}] }),
912        );
913        match ctx {
914            Some(ToolCallContext::ScriptStep {
915                current,
916                total,
917                step,
918            }) => {
919                assert_eq!(current, 0);
920                assert_eq!(total, 2);
921                assert_eq!(step, "starting");
922            }
923            other => panic!("expected ScriptStep, got {:?}", other),
924        }
925    }
926
927    #[test]
928    fn infer_context_browse_script_empty() {
929        let ctx = infer_context("browse_script", &json!({ "script": "" }));
930        #[cfg(feature = "native-browser")]
931        assert!(ctx.is_none());
932        #[cfg(not(feature = "native-browser"))]
933        assert!(ctx.is_none());
934    }
935
936    #[test]
937    fn infer_context_unknown_tool() {
938        let ctx = infer_context("bash", &json!({ "command": "ls" }));
939        assert!(ctx.is_none());
940    }
941
942    #[test]
943    fn infer_context_missing_args() {
944        // browse without url → None
945        let ctx = infer_context("browse", &json!({}));
946        assert!(ctx.is_none());
947
948        // web_search without query → None
949        let ctx = infer_context("web_search", &json!({}));
950        assert!(ctx.is_none());
951    }
952
953    #[test]
954    fn tool_context_serde_roundtrip() {
955        let contexts = vec![
956            ToolCallContext::WebSearch {
957                query: "test".into(),
958                engine: Some("ddg".into()),
959            },
960            ToolCallContext::PageVisit {
961                url: "https://example.com".into(),
962                reason: Some(VisitReason::DirectNavigation),
963                page_title: None,
964                page_status: None,
965                page_bytes: None,
966                page_duration_ms: None,
967                navigation_error: None,
968                screenshot: None,
969            },
970            ToolCallContext::PageVisit {
971                url: "https://example.com".into(),
972                reason: Some(VisitReason::SearchResult { position: 3 }),
973                page_title: None,
974                page_status: None,
975                page_bytes: None,
976                page_duration_ms: None,
977                navigation_error: None,
978                screenshot: None,
979            },
980            ToolCallContext::PageVisit {
981                url: "https://example.com".into(),
982                reason: None,
983                page_title: Some("Example Page".into()),
984                page_status: Some(200),
985                page_bytes: Some(12400),
986                page_duration_ms: Some(245),
987                navigation_error: None,
988                screenshot: None,
989            },
990            ToolCallContext::DataExtraction {
991                target: ".title".into(),
992                url: Some("https://example.com".into()),
993                result_count: None,
994                page_status: None,
995                page_duration_ms: None,
996            },
997            ToolCallContext::DataExtraction {
998                target: ".items".into(),
999                url: Some("https://shop.example.com/products".into()),
1000                result_count: Some(42),
1001                page_status: Some(200),
1002                page_duration_ms: Some(180),
1003            },
1004            ToolCallContext::SessionAction {
1005                action: "goto".into(),
1006                url: Some("https://example.com".into()),
1007            },
1008            ToolCallContext::ScriptStep {
1009                current: 3,
1010                total: 10,
1011                step: "clicking".into(),
1012            },
1013        ];
1014
1015        for ctx in &contexts {
1016            let json = serde_json::to_string(ctx).unwrap();
1017            let restored: ToolCallContext = serde_json::from_str(&json).unwrap();
1018            let json2 = serde_json::to_string(&restored).unwrap();
1019            assert_eq!(json, json2, "roundtrip failed for {:?}", ctx);
1020        }
1021    }
1022
1023    #[test]
1024    fn tool_execution_update_backward_compat() {
1025        // Old JSON without context field → deserializes with context: None
1026        let old_json = json!({
1027            "type": "toolExecutionUpdate",
1028            "tool_call_id": "call_123",
1029            "tool_name": "browse",
1030            "partial_result": "Loading...",
1031            "tab_id": null
1032        });
1033        let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1034        match event {
1035            crate::events::AgentEvent::ToolExecutionUpdate { context, .. } => {
1036                assert!(context.is_none());
1037            }
1038            other => panic!("expected ToolExecutionUpdate, got {:?}", other),
1039        }
1040    }
1041
1042    #[test]
1043    fn tool_execution_start_backward_compat() {
1044        // Old JSON without context field
1045        let old_json = json!({
1046            "type": "toolExecutionStart",
1047            "tool_call_id": "call_123",
1048            "tool_name": "browse",
1049            "args": { "url": "https://example.com" }
1050        });
1051        let event: crate::events::AgentEvent = serde_json::from_value(old_json).unwrap();
1052        match event {
1053            crate::events::AgentEvent::ToolExecutionStart { context, .. } => {
1054                assert!(context.is_none());
1055            }
1056            other => panic!("expected ToolExecutionStart, got {:?}", other),
1057        }
1058    }
1059
1060    #[test]
1061    fn browse_enrichment_callback_fills_page_visit() {
1062        use crate::tools::browse::BrowseProgress;
1063        use std::sync::Arc;
1064
1065        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1066            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1067                url: "https://example.com".into(),
1068                reason: Some(VisitReason::DirectNavigation),
1069                page_title: None,
1070                page_status: None,
1071                page_bytes: None,
1072                page_duration_ms: None,
1073                navigation_error: None,
1074                screenshot: None,
1075            })));
1076        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1077        cb(BrowseProgress::DocumentReady {
1078            url: "https://example.com/final".into(),
1079            title: "Example".into(),
1080            status: 200,
1081            bytes: 4096,
1082            duration_ms: 245,
1083        });
1084        let snapshot = cell.lock().clone();
1085        match snapshot {
1086            Some(ToolCallContext::PageVisit {
1087                url,
1088                page_title,
1089                page_status,
1090                page_bytes,
1091                page_duration_ms,
1092                ..
1093            }) => {
1094                assert_eq!(url, "https://example.com/final");
1095                assert_eq!(page_title.as_deref(), Some("Example"));
1096                assert_eq!(page_status, Some(200));
1097                assert_eq!(page_bytes, Some(4096));
1098                assert_eq!(page_duration_ms, Some(245));
1099            }
1100            other => panic!("expected PageVisit, got {:?}", other),
1101        }
1102    }
1103
1104    #[test]
1105    fn browse_enrichment_callback_fills_data_extraction() {
1106        use crate::tools::browse::BrowseProgress;
1107        use std::sync::Arc;
1108
1109        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1110            parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1111                target: ".item".into(),
1112                url: Some("https://shop.example.com".into()),
1113                result_count: None,
1114                page_status: None,
1115                page_duration_ms: None,
1116            })),
1117        );
1118        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1119        cb(BrowseProgress::DocumentReady {
1120            url: "https://shop.example.com".into(),
1121            title: "Shop".into(),
1122            status: 200,
1123            bytes: 8192,
1124            duration_ms: 180,
1125        });
1126        let snapshot = cell.lock().clone();
1127        match snapshot {
1128            Some(ToolCallContext::DataExtraction {
1129                page_status,
1130                page_duration_ms,
1131                ..
1132            }) => {
1133                assert_eq!(page_status, Some(200));
1134                assert_eq!(page_duration_ms, Some(180));
1135            }
1136            other => panic!("expected DataExtraction, got {:?}", other),
1137        }
1138    }
1139
1140    #[test]
1141    fn browse_enrichment_callback_no_op_for_mismatched() {
1142        use crate::tools::browse::BrowseProgress;
1143        use std::sync::Arc;
1144
1145        // DocumentReady + ScriptStep → no-op
1146        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1147            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::ScriptStep {
1148                current: 1,
1149                total: 5,
1150                step: "click".into(),
1151            })));
1152        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1153        cb(BrowseProgress::DocumentReady {
1154            url: "x".into(),
1155            title: "t".into(),
1156            status: 200,
1157            bytes: 0,
1158            duration_ms: 0,
1159        });
1160        // ScriptStep should be untouched
1161        assert!(matches!(
1162            cell.lock().as_ref(),
1163            Some(ToolCallContext::ScriptStep { .. })
1164        ));
1165    }
1166
1167    #[test]
1168    fn browse_enrichment_callback_fills_navigation_error() {
1169        use crate::tools::browse::BrowseProgress;
1170        use std::sync::Arc;
1171
1172        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1173            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1174                url: "https://example.com".into(),
1175                reason: Some(VisitReason::DirectNavigation),
1176                page_title: None,
1177                page_status: None,
1178                page_bytes: None,
1179                page_duration_ms: None,
1180                navigation_error: None,
1181                screenshot: None,
1182            })));
1183        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1184        cb(BrowseProgress::NavigationFailed {
1185            url: "https://example.com".into(),
1186            error: "connection refused".into(),
1187        });
1188        let snapshot = cell.lock().clone();
1189        match snapshot {
1190            Some(ToolCallContext::PageVisit {
1191                navigation_error, ..
1192            }) => {
1193                assert_eq!(navigation_error.as_deref(), Some("connection refused"));
1194            }
1195            other => panic!("expected PageVisit, got {:?}", other),
1196        }
1197    }
1198
1199    #[test]
1200    fn browse_enrichment_callback_fills_screenshot() {
1201        use crate::tools::browse::BrowseProgress;
1202        use std::sync::Arc;
1203
1204        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
1205            Arc::new(parking_lot::Mutex::new(Some(ToolCallContext::PageVisit {
1206                url: "https://example.com".into(),
1207                reason: Some(VisitReason::DirectNavigation),
1208                page_title: None,
1209                page_status: None,
1210                page_bytes: None,
1211                page_duration_ms: None,
1212                navigation_error: None,
1213                screenshot: None,
1214            })));
1215        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1216        cb(BrowseProgress::ScreenshotCaptured {
1217            bytes: 2048,
1218            width: 800,
1219            duration_ms: 120,
1220        });
1221        let snapshot = cell.lock().clone();
1222        match snapshot {
1223            Some(ToolCallContext::PageVisit { screenshot, .. }) => {
1224                let meta = screenshot.expect("screenshot should be set");
1225                assert_eq!(meta.bytes, 2048);
1226                assert_eq!(meta.width, 800);
1227                assert_eq!(meta.duration_ms, 120);
1228            }
1229            other => panic!("expected PageVisit, got {:?}", other),
1230        }
1231    }
1232
1233    #[test]
1234    fn browse_enrichment_callback_navigation_failed_ignores_non_page_visit() {
1235        use crate::tools::browse::BrowseProgress;
1236        use std::sync::Arc;
1237
1238        // NavigationFailed + DataExtraction → no-op
1239        let cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> = Arc::new(
1240            parking_lot::Mutex::new(Some(ToolCallContext::DataExtraction {
1241                target: ".title".into(),
1242                url: None,
1243                result_count: None,
1244                page_status: None,
1245                page_duration_ms: None,
1246            })),
1247        );
1248        let cb = make_browse_enrichment_cb(Arc::clone(&cell));
1249        cb(BrowseProgress::NavigationFailed {
1250            url: "https://example.com".into(),
1251            error: "timeout".into(),
1252        });
1253        assert!(matches!(
1254            cell.lock().as_ref(),
1255            Some(ToolCallContext::DataExtraction { .. })
1256        ));
1257    }
1258}