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(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
275/// Check whether a tool call requires approval before execution.
276///
277/// Returns `None` if approval is not required or was granted.
278/// Returns `Some(AgentToolResult)` (error) if denied or approval-required.
279/// In the latter case, also emits [`AgentEvent::ApprovalRequired`].
280async 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    // No tiers configured = no approval gating.
292    if config.require_approval_for.is_empty() {
293        return None;
294    }
295
296    // No hook registered = permissive.
297    let hook = config.hook.as_ref()?;
298
299    // Get the tool's tier. Default Exec (safest) if tool not found.
300    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    // This tier doesn't require approval.
307    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 // Allow on hook error (fail open logged).
336        }
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        // Check cancellation before executing each tool.
370        // This allows Ctrl+C to interrupt a batch of tool calls
371        // without waiting for all of them to complete.
372        if loop_ref.is_cancelled() {
373            tracing::info!(
374                "[TOOL-EXEC] Cancelled before executing tool {}",
375                tool_call.name
376            );
377            break;
378        }
379        // Clone tool_call fields once upfront to avoid repeated clones.
380        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        // Check approval before executing the tool.
398        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        // Check cancellation before preparing each tool.
518        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        // Clone tool_call fields once upfront to avoid repeated clones.
526        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        // Check approval before preparing the tool.
544        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            // Pre-build the cancellation notify *outside* the async move
616            // closure so `loop_ref` does not need to be `Send + 'static`.
617            // The helper only borrows `loop_ref` for the duration of the
618            // synchronous body of `make_cancellation` (it returns
619            // `Arc<Notify>` plus a detached `tokio::spawn` that owns the
620            // flag clone, so by the time this scope ends `loop_ref` is
621            // no longer referenced).
622            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        // Poll futures with periodic cancel checks.
664        // Uses `FuturesUnordered` so we can drain completed results as they
665        // arrive and detect cancellation without waiting for all futures.
666        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        // Check cancel every 100ms so Ctrl+C is responsive even when
672        // tool calls are slow.
673        let mut cancel_interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
674        cancel_interval.tick().await; // consume immediate first tick
675
676        loop {
677            tokio::select! {
678                result = active.next() => {
679                    match result {
680                        Some((idx, finalized)) => {
681                            slots[idx] = Some(finalized);
682                        }
683                        None => break, // all futures completed
684                    }
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                        // Don't abort futures — let them finish (they may have
693                        // side effects). But skip waiting and return what we have.
694                        break;
695                    }
696                }
697            }
698        }
699
700        // Drain any remaining futures that completed before cancellation.
701        while let Some(result) = active.next().now_or_never().flatten() {
702            slots[result.0] = Some(result.1);
703        }
704    }
705
706    // Slots for futures that were still running at cancellation time remain None.
707    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        // Infer semantic context — same as sequential path.
744        let context = infer_context(&tool_name, &args);
745
746        // Shared context cell for progressive enrichment.
747        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
748            Arc::new(parking_lot::Mutex::new(context));
749
750        // Tab ID slot.
751        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        // String progress callback.
756        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        // Browse progress callback — enriches context cell.
776        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
777        // F-8 (audit 2026-06-21): see the matching note in
778        // `execute_prepared_tool_call`. Same `select!`-based cancellation
779        // wrap, same trade-off — additive, no trait change.
780        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(&notify);
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        // Infer semantic context from tool name + args.
905        let context = infer_context(&tool_name, &prepared.args);
906
907        // Shared mutable context cell. The String progress callback reads
908        // from here; the browse progress callback writes enriched fields.
909        let context_cell: Arc<parking_lot::Mutex<Option<ToolCallContext>>> =
910            Arc::new(parking_lot::Mutex::new(context));
911
912        // Shared slot for the active tab ID. Tools that manage browser tabs
913        // (BrowseTool) populate this when they open a tab; the progress
914        // callback reads it to include `tab_id` in `ToolExecutionUpdate`.
915        let tab_id_slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
916            Arc::new(parking_lot::Mutex::new(None));
917
918        // Pass the slot to the tool so it can write the tab_id.
919        tool.set_tab_id_slot(Arc::clone(&tab_id_slot));
920
921        // String progress callback — reads context from shared cell.
922        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        // Wire up progress callback BEFORE execute — pi-mono: tool's onUpdate
937        tool.on_progress(progress_callback(move |msg: String| {
938            progress_cb(msg);
939        }));
940
941        // Browse progress callback — enriches context cell with structured data.
942        // Wire up browse progress callback.
943        tool.on_browse_progress(make_browse_enrichment_cb(Arc::clone(&context_cell)));
944
945        // F-8 (audit 2026-06-21): wrap tool execution in a `tokio::select!`
946        // against the loop's cancellation notify. Before this, every call
947        // site passed `None` for the `signal: Option<oneshot::Receiver<()>>`
948        // parameter, defeating the cancellation contract — a long-running
949        // tool (e.g. `bash` with a 30s sleep) would only observe a cancel
950        // flag on the next 500 ms poll cycle, *after* it had already done
951        // most of its work. With `select!`, the tool's `await` is dropped
952        // the instant `cancel_signal` flips, returning control to the loop
953        // within ~250 ms (the poll cadence in `make_cancellation`).
954        //
955        // The tool's own `signal` argument is still `None` here — that
956        // parameter would require a trait signature change. `select!`
957        // cancellation at the call site is the minimal, additive fix
958        // that preserves the existing trait contract while honoring
959        // cancellation at the *outer* await point. Tools that respect
960        // `ctx.cancelled` (most do) still observe the loop's stop flag
961        // through `ctx`; tools that ignore it now at least get their
962        // outer future cancelled promptly.
963        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        // browse without url → None
1109        let ctx = infer_context("browse", &json!({}));
1110        assert!(ctx.is_none());
1111
1112        // web_search without query → None
1113        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        // Old JSON without context field → deserializes with context: None
1190        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        // Old JSON without context field
1209        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        // DocumentReady + ScriptStep → no-op
1310        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        // ScriptStep should be untouched
1325        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        // NavigationFailed + DataExtraction → no-op
1403        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}