Skip to main content

clark_agent/
exec.rs

1//! Tool batch execution.
2//!
3//! Canonical prepare / execute / finalize chain for model-emitted tool
4//! calls.
5//!
6//! Two modes:
7//!
8//! - **Parallel** (default): all tools in the batch prep sequentially,
9//!   then run concurrently, then finalize sequentially in source order.
10//! - **Sequential**: each tool is prepped, executed, and finalized
11//!   before the next starts. Triggered by either:
12//!     - any tool in the batch setting `requires_exclusive_sandbox = true`, or
13//!     - `LoopConfig.default_execution_mode = Sequential` (loop-wide pin).
14//!
15//! Hook plumbing:
16//! - `BeforeToolCall::on_before_tool_call` runs after argument validation,
17//!   before `tool.execute`. May `block` to short-circuit with an error
18//!   tool result.
19//! - `AfterToolCall::on_after_tool_call` runs after `tool.execute`. May
20//!   `override_result`, `mark_error`, or vote `terminate`.
21
22use std::sync::Arc;
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25use serde_json::{json, Value};
26use tokio::sync::mpsc;
27use tokio::time::timeout;
28use tokio_util::sync::CancellationToken;
29
30use crate::config::LoopConfig;
31use crate::error::{LoopError, ToolError};
32use crate::event::{AgentEvent, EventSink};
33use crate::plugin::{AfterToolCallContext, BeforeToolCallContext, EventObserver};
34use crate::tool::{detect_arg_parse_error, AgentTool, ExecutionMode, ToolCall, ToolResult};
35use crate::types::{AgentContext, AgentMessage, AssistantContent, ToolResultContent};
36
37const TOOL_UPDATE_DRAIN_GRACE: Duration = Duration::from_millis(50);
38const TOOL_UPDATE_EVENT_QUEUE_CAPACITY: usize = 256;
39
40fn spawn_tool_update_dispatcher(
41    event_sink: Arc<dyn EventSink>,
42    observers: Vec<Arc<dyn EventObserver>>,
43) -> mpsc::Sender<AgentEvent> {
44    let (tx, mut rx) = mpsc::channel::<AgentEvent>(TOOL_UPDATE_EVENT_QUEUE_CAPACITY);
45    tokio::spawn(async move {
46        while let Some(event) = rx.recv().await {
47            event_sink.emit(event.clone()).await;
48            for observer in observers.iter() {
49                observer.on_event(&event).await;
50            }
51        }
52    });
53    tx
54}
55
56fn enqueue_tool_update_event(tx: &mpsc::Sender<AgentEvent>, event: AgentEvent) {
57    match tx.try_send(event) {
58        Ok(()) => {}
59        Err(mpsc::error::TrySendError::Full(_)) => {
60            tracing::warn!("tool update event queue full; dropping partial update");
61        }
62        Err(mpsc::error::TrySendError::Closed(_)) => {}
63    }
64}
65
66/// Result of executing one batch.
67pub(crate) struct ExecutedBatch {
68    /// Tool result messages in source order, ready to push to history.
69    pub messages: Vec<AgentMessage>,
70    /// Unanimous-vote terminate signal: true when every finalized result
71    /// in the batch had `terminate = true`. Empty batches return false.
72    pub terminate: bool,
73}
74
75pub(crate) async fn execute_tool_batch(
76    assistant: &AgentMessage,
77    tool_calls: Vec<ToolCall>,
78    context: &AgentContext,
79    config: &LoopConfig,
80    signal: &CancellationToken,
81    turn_allowlist: Option<&std::collections::HashSet<String>>,
82) -> Result<ExecutedBatch, LoopError> {
83    if tool_calls.is_empty() {
84        return Ok(ExecutedBatch {
85            messages: Vec::new(),
86            terminate: false,
87        });
88    }
89
90    // Let the active protocol policy normalize the batch before registry
91    // lookup — e.g. fold a product's known alias names into a canonical
92    // tool. The default policy is a no-op; the core performs no alias
93    // repair of its own, so no product tool vocabulary lives here.
94    let mut tool_calls = tool_calls;
95    config
96        .protocol
97        .normalize_tool_calls(&mut tool_calls, &config.tools);
98
99    let total_tool_calls = tool_calls.len();
100    let limit_counted_tool_calls = count_limit_counted_tool_calls(&tool_calls, &config.tools);
101    let (tool_calls, unexecuted_tool_calls, max_executed) =
102        split_tool_calls_for_execution(tool_calls, &config.tools, config.max_tool_calls_per_turn);
103
104    let assistant_content = match assistant {
105        AgentMessage::Assistant { content, .. } => content.clone(),
106        _ => AssistantContent { blocks: Vec::new() },
107    };
108
109    if tool_calls.is_empty() {
110        let messages = synthesize_unexecuted_tool_results(
111            assistant,
112            &assistant_content,
113            unexecuted_tool_calls,
114            total_tool_calls,
115            limit_counted_tool_calls,
116            max_executed.unwrap_or(0),
117            context,
118            config,
119        )
120        .await;
121        return Ok(ExecutedBatch {
122            messages,
123            terminate: false,
124        });
125    }
126
127    // A batch downgrades to Sequential when either (a) the loop is
128    // pinned to Sequential mode, or (b) any participating tool needs
129    // exclusive sandbox access.
130    let any_exclusive = tool_calls.iter().any(|call| {
131        config
132            .tools
133            .get(&call.name)
134            .map(|t| t.requires_exclusive_sandbox())
135            .unwrap_or(false)
136    });
137
138    let effective_mode =
139        if any_exclusive || config.default_execution_mode == ExecutionMode::Sequential {
140            ExecutionMode::Sequential
141        } else {
142            ExecutionMode::Parallel
143        };
144
145    let mut batch = match effective_mode {
146        ExecutionMode::Sequential => {
147            execute_sequential(
148                assistant,
149                &assistant_content,
150                tool_calls,
151                context,
152                config,
153                signal,
154                turn_allowlist,
155            )
156            .await
157        }
158        ExecutionMode::Parallel => {
159            execute_parallel(
160                assistant,
161                &assistant_content,
162                tool_calls,
163                context,
164                config,
165                signal,
166                turn_allowlist,
167            )
168            .await
169        }
170    }?;
171
172    if !unexecuted_tool_calls.is_empty() {
173        batch.messages.extend(
174            synthesize_unexecuted_tool_results(
175                assistant,
176                &assistant_content,
177                unexecuted_tool_calls,
178                total_tool_calls,
179                limit_counted_tool_calls,
180                max_executed.unwrap_or(0),
181                context,
182                config,
183            )
184            .await,
185        );
186        batch.terminate = false;
187    }
188
189    Ok(batch)
190}
191
192fn split_tool_calls_for_execution(
193    tool_calls: Vec<ToolCall>,
194    tools: &crate::tool::ToolRegistry,
195    max_tool_calls: Option<usize>,
196) -> (Vec<ToolCall>, Vec<ToolCall>, Option<usize>) {
197    let Some(max_tool_calls) = max_tool_calls else {
198        return (tool_calls, Vec::new(), None);
199    };
200    let max_tool_calls = max_tool_calls.max(1);
201    if count_limit_counted_tool_calls(&tool_calls, tools) <= max_tool_calls {
202        return (tool_calls, Vec::new(), Some(max_tool_calls));
203    }
204
205    let mut executable = Vec::with_capacity(tool_calls.len());
206    let mut unexecuted = Vec::new();
207    let mut executed_counted = 0usize;
208    for call in tool_calls {
209        if !tool_counts_toward_call_limit(tools, &call.name) {
210            // Progress-only tools, parallel-safe reads, and malformed calls
211            // that resolve to no registered tool never burn the per-turn cap;
212            // let them all through (see `tool_counts_toward_call_limit`). A
213            // malformed call still lands as a synthetic "no such tool" error
214            // in `prepare_call`; it just cannot preempt a real call's slot.
215            executable.push(call);
216        } else if executed_counted < max_tool_calls {
217            executed_counted += 1;
218            executable.push(call);
219        } else {
220            unexecuted.push(call);
221        }
222    }
223    (executable, unexecuted, Some(max_tool_calls))
224}
225
226fn count_limit_counted_tool_calls(
227    tool_calls: &[ToolCall],
228    tools: &crate::tool::ToolRegistry,
229) -> usize {
230    tool_calls
231        .iter()
232        .filter(|call| tool_counts_toward_call_limit(tools, &call.name))
233        .count()
234}
235
236/// Whether a tool consumes a slot from the per-turn cap.
237///
238/// A call is exempt from the cap when ANY of these hold:
239/// - it opts out of the cap (progress-only signals that report status
240///   without doing work — see [`AgentTool::counts_toward_tool_call_limit`]);
241/// - it is marked parallel-safe (idempotent reads like `web_search`,
242///   `file_read`, `grep`, `glob`);
243/// - it does not resolve to a registered tool at all — an empty/blank name,
244///   or a name no tool exists for.
245///
246/// The last case is the load-bearing one. A call that resolves to no tool
247/// does NO real work: `prepare_call` short-circuits it to a synchronous
248/// "no such tool" error result without ever invoking a tool. Counting it
249/// would let a glitch — e.g. a streamed tool call that arrived with an empty
250/// `name` — spend the turn's only slot and bump a *real* call into the
251/// unexecuted bin. That was observed in production: under
252/// `max_tool_calls_per_turn = 1`, an empty-name call preempted the model's
253/// real next call. A malformed call must never preempt real work; it still
254/// surfaces its error so the model can react next turn.
255///
256/// Note this is the opposite of the *termination*-vote rule: there, an
257/// unresolved name DOES count (see `tool_counts_toward_termination_vote`),
258/// so a stray call cannot accidentally end the run. The asymmetry is
259/// intentional — a malformed call does no work (so it must not spend the
260/// work budget) and is not a satisfied terminator (so it must not vote to
261/// stop).
262fn tool_counts_toward_call_limit(tools: &crate::tool::ToolRegistry, name: &str) -> bool {
263    tools
264        .get(name)
265        .map(|tool| tool.counts_toward_tool_call_limit() && !tool.parallel_safe_per_turn())
266        .unwrap_or(false)
267}
268
269/// Whether a tool's terminate vote is counted in the unanimous-vote
270/// tally. Unknown / unregistered names default to `true` so a stray
271/// tool call cannot accidentally end the run by being treated as
272/// advisory. See `AgentTool::counts_toward_termination_vote`.
273fn tool_counts_toward_termination_vote(tools: &crate::tool::ToolRegistry, name: &str) -> bool {
274    tools
275        .get(name)
276        .map(|tool| tool.counts_toward_termination_vote())
277        .unwrap_or(true)
278}
279
280/// Compute the batch-level terminate signal, ignoring tools that opt
281/// out via `counts_toward_termination_vote() == false`.
282///
283/// The batch terminates iff:
284/// - at least one *counted* tool is present, AND
285/// - every counted tool voted `terminate: true`.
286///
287/// An all-advisory batch (e.g. only progress-note calls) returns
288/// `false` because no counted tool voted yes — progress notes never
289/// end the run on their own.
290///
291/// When the batch terminates AND advisory siblings were skipped from
292/// the tally, emits a structured `tracing::info` line so operators can
293/// measure how often this fallback actually fires in production — a
294/// non-zero rate names which model still needs the safety net.
295fn compute_batch_terminate<'a, I>(tools: &crate::tool::ToolRegistry, votes: I) -> bool
296where
297    I: IntoIterator<Item = (&'a str, bool)>,
298{
299    let mut counted_total = 0usize;
300    let mut counted_terminate = 0usize;
301    let mut terminating: Vec<&'a str> = Vec::new();
302    let mut advisory_skipped: Vec<&'a str> = Vec::new();
303    for (name, terminate) in votes {
304        if !tool_counts_toward_termination_vote(tools, name) {
305            advisory_skipped.push(name);
306            continue;
307        }
308        counted_total += 1;
309        if terminate {
310            counted_terminate += 1;
311            terminating.push(name);
312        }
313    }
314    let terminated = counted_total > 0 && counted_terminate == counted_total;
315    if terminated && !advisory_skipped.is_empty() {
316        tracing::info!(
317            terminating_tools = ?terminating,
318            advisory_tools = ?advisory_skipped,
319            counted_total,
320            "advisory siblings excluded from unanimous termination vote"
321        );
322    }
323    terminated
324}
325
326// The execution helpers share the same loop context tuple. Keeping the
327// signatures explicit is clearer than introducing a one-off bag of references.
328#[allow(clippy::too_many_arguments)]
329async fn synthesize_unexecuted_tool_results(
330    assistant: &AgentMessage,
331    assistant_content: &AssistantContent,
332    tool_calls: Vec<ToolCall>,
333    total_tool_calls: usize,
334    limit_counted_tool_calls: usize,
335    max_executed: usize,
336    context: &AgentContext,
337    config: &LoopConfig,
338) -> Vec<AgentMessage> {
339    let mut messages = Vec::with_capacity(tool_calls.len());
340    for call in tool_calls {
341        let outcome = finalize(
342            assistant,
343            assistant_content,
344            &call,
345            &call.arguments,
346            ExecutedOutcome {
347                result: unexecuted_tool_call_result(
348                    total_tool_calls,
349                    limit_counted_tool_calls,
350                    max_executed,
351                ),
352                is_error: true,
353            },
354            &context.messages,
355            &config.plugins.after_tool_call,
356        )
357        .await;
358        emit_tool_end(config, &call, &outcome).await;
359        messages.push(outcome_to_message(&call, outcome));
360    }
361    messages
362}
363
364fn unexecuted_tool_call_message(
365    total_tool_calls: usize,
366    limit_counted_tool_calls: usize,
367    max_executed: usize,
368) -> String {
369    let call_word = if total_tool_calls == 1 {
370        "tool call"
371    } else {
372        "tool calls"
373    };
374    let limited_call_word = if limit_counted_tool_calls == 1 {
375        "limit-counted tool call"
376    } else {
377        "limit-counted tool calls"
378    };
379    let executed_word = if max_executed == 1 { "call" } else { "calls" };
380    if limit_counted_tool_calls != total_tool_calls {
381        return format!(
382            "This tool call was not executed because the assistant turn emitted \
383             {limit_counted_tool_calls} {limited_call_word} ({total_tool_calls} \
384             {call_word} total, including progress-only calls), but only the \
385             first {max_executed} limit-counted {executed_word} can run in one \
386             turn. The earlier allowed calls already ran. Reissue this call in \
387             a later turn, one tool call at a time."
388        );
389    }
390    format!(
391        "This tool call was not executed because the assistant turn emitted \
392         {total_tool_calls} {call_word}, but only the first {max_executed} \
393         {executed_word} can run in one turn. The earlier {max_executed} \
394         {executed_word} already ran. Reissue this call in a later turn, \
395         one tool call at a time."
396    )
397}
398
399fn unexecuted_tool_call_result(
400    total_tool_calls: usize,
401    limit_counted_tool_calls: usize,
402    max_executed: usize,
403) -> ToolResult {
404    let mut result = ToolResult::error(unexecuted_tool_call_message(
405        total_tool_calls,
406        limit_counted_tool_calls,
407        max_executed,
408    ));
409    result.details = json!({
410        "kind": "tool_call_not_executed",
411        "reason": "max_tool_calls_per_turn",
412        "total_tool_calls": total_tool_calls,
413        "limit_counted_tool_calls": limit_counted_tool_calls,
414        "max_executed": max_executed,
415    });
416    result
417}
418
419#[allow(clippy::too_many_arguments)]
420async fn execute_sequential(
421    assistant: &AgentMessage,
422    assistant_content: &AssistantContent,
423    tool_calls: Vec<ToolCall>,
424    context: &AgentContext,
425    config: &LoopConfig,
426    signal: &CancellationToken,
427    turn_allowlist: Option<&std::collections::HashSet<String>>,
428) -> Result<ExecutedBatch, LoopError> {
429    let mut messages = Vec::with_capacity(tool_calls.len());
430    let mut votes: Vec<(String, bool)> = Vec::with_capacity(tool_calls.len());
431
432    let mut remaining_calls = tool_calls.into_iter();
433    while let Some(call) = remaining_calls.next() {
434        let outcome = run_one(
435            assistant,
436            assistant_content,
437            &call,
438            context,
439            config,
440            signal,
441            turn_allowlist,
442        )
443        .await?;
444        let abort_remaining = outcome.is_error
445            && config
446                .tools
447                .get(&call.name)
448                .map(|tool| tool.aborts_siblings_on_error())
449                .unwrap_or(false);
450        votes.push((call.name.clone(), outcome.terminate));
451        messages.push(outcome_to_message(&call, outcome));
452
453        if abort_remaining {
454            let skipped_calls: Vec<_> = remaining_calls.collect();
455            votes.extend(skipped_calls.iter().map(|call| (call.name.clone(), false)));
456            messages.extend(
457                synthesize_aborted_sibling_tool_results(
458                    assistant,
459                    assistant_content,
460                    skipped_calls,
461                    &call.name,
462                    context,
463                    config,
464                )
465                .await,
466            );
467            break;
468        }
469    }
470
471    let terminate =
472        compute_batch_terminate(&config.tools, votes.iter().map(|(n, t)| (n.as_str(), *t)));
473
474    Ok(ExecutedBatch {
475        messages,
476        terminate,
477    })
478}
479
480/// Synthesize recoverable results for sequential siblings that never started
481/// because an earlier tool explicitly declared its failure to be a dependency
482/// boundary for the rest of the batch.
483#[allow(clippy::too_many_arguments)]
484async fn synthesize_aborted_sibling_tool_results(
485    assistant: &AgentMessage,
486    assistant_content: &AssistantContent,
487    tool_calls: Vec<ToolCall>,
488    failed_tool_name: &str,
489    context: &AgentContext,
490    config: &LoopConfig,
491) -> Vec<AgentMessage> {
492    let mut messages = Vec::with_capacity(tool_calls.len());
493    for call in tool_calls {
494        let mut result = ToolResult::error(format!(
495            "This tool call was not executed because earlier sibling tool \
496             `{failed_tool_name}` failed and declared later work dependent on \
497             its success. Correct that error, then reissue this call in a later turn."
498        ));
499        result.details = json!({
500            "kind": "tool_call_not_executed",
501            "reason": "sibling_tool_error",
502            "failed_tool": failed_tool_name,
503        });
504        let outcome = finalize(
505            assistant,
506            assistant_content,
507            &call,
508            &call.arguments,
509            ExecutedOutcome {
510                result,
511                is_error: true,
512            },
513            &context.messages,
514            &config.plugins.after_tool_call,
515        )
516        .await;
517        emit_tool_end(config, &call, &outcome).await;
518        messages.push(outcome_to_message(&call, outcome));
519    }
520    messages
521}
522
523#[allow(clippy::too_many_arguments)]
524async fn execute_parallel(
525    assistant: &AgentMessage,
526    assistant_content: &AssistantContent,
527    tool_calls: Vec<ToolCall>,
528    context: &AgentContext,
529    config: &LoopConfig,
530    signal: &CancellationToken,
531    turn_allowlist: Option<&std::collections::HashSet<String>>,
532) -> Result<ExecutedBatch, LoopError> {
533    use futures::stream::{FuturesUnordered, StreamExt};
534
535    // Per-batch cancellation lever. As a child of `signal` it auto-
536    // cancels when the run-wide signal cancels (so tools react to the
537    // user's abort). It can also be cancelled independently on
538    // sibling-error opt-in (`AgentTool::aborts_siblings_on_error`),
539    // propagating only to siblings in *this* batch — neither sibling
540    // failures nor sibling-triggered cancels affect the run-wide
541    // signal.
542    let batch_token = signal.child_token();
543
544    // Prep + emit starts sequentially so validation and event ordering are
545    // deterministic. A start means the real tool implementation is about to
546    // run; parse, registry, validation, and before-hook failures never start.
547    let mut prepared: Vec<(ToolCall, PreparedCall)> = Vec::with_capacity(tool_calls.len());
548    for call in tool_calls {
549        let prep = prepare_call(
550            assistant,
551            assistant_content,
552            &call,
553            context,
554            config,
555            turn_allowlist,
556        )
557        .await;
558        if matches!(prep, PreparedCall::Prepared { .. }) {
559            emit_tool_start(config, &call).await;
560        }
561        prepared.push((call, prep));
562    }
563
564    let mut futures = Vec::with_capacity(prepared.len());
565    let mut immediate: Vec<(usize, ToolCall, FinalizedOutcome)> = Vec::new();
566
567    for (idx, (call, prep)) in prepared.into_iter().enumerate() {
568        match prep {
569            PreparedCall::Immediate(executed) => {
570                // Route Immediate outcomes through finalize so
571                // AfterToolCall hooks observe every tool result —
572                // including arg-parse / validation / before-block
573                // errors. The `args` we hand to hooks is the original
574                // (potentially sentinel-bearing) call arguments since
575                // we never built prepared args for short-circuited
576                // calls.
577                let finalized = finalize(
578                    assistant,
579                    assistant_content,
580                    &call,
581                    &call.arguments,
582                    executed,
583                    &context.messages,
584                    &config.plugins.after_tool_call,
585                )
586                .await;
587                immediate.push((idx, call, finalized));
588            }
589            PreparedCall::Prepared { tool, args } => {
590                let tool_signal = batch_token.child_token();
591                let run_signal = signal.clone();
592                let batch_token_clone = batch_token.clone();
593                let assistant_clone = assistant.clone();
594                let assistant_content_clone = assistant_content.clone();
595                let context_messages = context.messages.clone();
596                let after_hooks = config.plugins.after_tool_call.clone();
597                let event_sink = config.event_sink.clone();
598                let event_observers = config.plugins.event_observer.clone();
599                let call_clone = call.clone();
600                let fut = async move {
601                    let id = call_clone.id.clone();
602                    let name = call_clone.name.clone();
603                    let name_for_message = name.clone();
604                    let update_events = spawn_tool_update_dispatcher(event_sink, event_observers);
605                    let executed_result = execute_prepared(
606                        &tool,
607                        &call_clone,
608                        args.clone(),
609                        tool_signal,
610                        Box::new(move |update| {
611                            let event = AgentEvent::ToolExecutionUpdate {
612                                tool_call_id: id.clone(),
613                                tool_name: name.clone(),
614                                partial: update,
615                            };
616                            enqueue_tool_update_event(&update_events, event);
617                        }),
618                    )
619                    .await;
620                    let executed = match executed_result {
621                        Ok(executed) => executed,
622                        Err(LoopError::Aborted)
623                            if batch_token_clone.is_cancelled() && !run_signal.is_cancelled() =>
624                        {
625                            // Sibling abort, not user abort. Convert
626                            // to a recoverable tool result so the
627                            // model sees what happened next turn and
628                            // the unanimous-vote termination rule
629                            // stays intact.
630                            ExecutedOutcome {
631                                result: ToolResult::error(format!(
632                                    "aborted because a sibling tool in the \
633                                     parallel batch errored — re-run this \
634                                     {name_for_message} call after addressing the \
635                                     sibling failure"
636                                )),
637                                is_error: true,
638                            }
639                        }
640                        Err(other) => return Err(other),
641                    };
642                    let finalized = finalize(
643                        &assistant_clone,
644                        &assistant_content_clone,
645                        &call_clone,
646                        &args,
647                        executed,
648                        &context_messages,
649                        &after_hooks,
650                    )
651                    .await;
652                    Ok::<_, LoopError>((idx, call_clone, finalized))
653                };
654                futures.push(fut);
655            }
656        }
657    }
658
659    // Drain futures as they complete. When an opted-in tool returns
660    // an error, cancel `batch_token` so still-running siblings exit
661    // promptly (cooperatively — they must check the signal). The
662    // futures already in flight that complete *before* the trigger
663    // produce their natural result. Cancelled siblings produce a
664    // typed `is_error: true` ToolResult via the match arm above.
665    let mut unordered: FuturesUnordered<_> = futures.into_iter().collect();
666    let mut completed: Vec<(usize, ToolCall, FinalizedOutcome)> =
667        Vec::with_capacity(unordered.len() + immediate.len());
668    while let Some(result) = unordered.next().await {
669        let entry = result?;
670        if entry.2.is_error {
671            let aborts = config
672                .tools
673                .get(&entry.1.name)
674                .map(|t| t.aborts_siblings_on_error())
675                .unwrap_or(false);
676            if aborts && !batch_token.is_cancelled() {
677                batch_token.cancel();
678            }
679        }
680        completed.push(entry);
681    }
682    completed.extend(immediate);
683    completed.sort_by_key(|(idx, _, _)| *idx);
684
685    let mut messages = Vec::with_capacity(completed.len());
686    let mut votes: Vec<(String, bool)> = Vec::with_capacity(completed.len());
687    for (_idx, call, outcome) in completed {
688        emit_tool_end(config, &call, &outcome).await;
689        votes.push((call.name.clone(), outcome.terminate));
690        messages.push(outcome_to_message(&call, outcome));
691    }
692
693    let terminate =
694        compute_batch_terminate(&config.tools, votes.iter().map(|(n, t)| (n.as_str(), *t)));
695
696    Ok(ExecutedBatch {
697        messages,
698        terminate,
699    })
700}
701
702/// Execute one tool call synchronously: prep → execute → finalize.
703/// Used by the sequential path.
704#[allow(clippy::too_many_arguments)]
705async fn run_one(
706    assistant: &AgentMessage,
707    assistant_content: &AssistantContent,
708    call: &ToolCall,
709    context: &AgentContext,
710    config: &LoopConfig,
711    signal: &CancellationToken,
712    turn_allowlist: Option<&std::collections::HashSet<String>>,
713) -> Result<FinalizedOutcome, LoopError> {
714    let prep = prepare_call(
715        assistant,
716        assistant_content,
717        call,
718        context,
719        config,
720        turn_allowlist,
721    )
722    .await;
723    let outcome = match prep {
724        PreparedCall::Immediate(executed) => {
725            finalize(
726                assistant,
727                assistant_content,
728                call,
729                &call.arguments,
730                executed,
731                &context.messages,
732                &config.plugins.after_tool_call,
733            )
734            .await
735        }
736        PreparedCall::Prepared { tool, args } => {
737            emit_tool_start(config, call).await;
738            let event_sink = config.event_sink.clone();
739            let event_observers = config.plugins.event_observer.clone();
740            let id = call.id.clone();
741            let name = call.name.clone();
742            let update_events = spawn_tool_update_dispatcher(event_sink, event_observers);
743            let executed = execute_prepared(
744                &tool,
745                call,
746                args.clone(),
747                signal.clone(),
748                Box::new(move |update| {
749                    let event = AgentEvent::ToolExecutionUpdate {
750                        tool_call_id: id.clone(),
751                        tool_name: name.clone(),
752                        partial: update,
753                    };
754                    enqueue_tool_update_event(&update_events, event);
755                }),
756            )
757            .await?;
758            finalize(
759                assistant,
760                assistant_content,
761                call,
762                &args,
763                executed,
764                &context.messages,
765                &config.plugins.after_tool_call,
766            )
767            .await
768        }
769    };
770
771    emit_tool_end(config, call, &outcome).await;
772    Ok(outcome)
773}
774
775// ─── Internal pipeline ────────────────────────────────────────────
776
777enum PreparedCall {
778    /// Argument validation, parse-error detection, or `BeforeToolCall`
779    /// short-circuited the call. The loop emits the error tool result
780    /// without invoking `tool.execute`, but still runs `AfterToolCall`
781    /// hooks so observers (terminal-message guard, system-reminder hook,
782    /// etc.) see every tool result — successes and failures alike.
783    Immediate(ExecutedOutcome),
784    /// Ready to execute.
785    Prepared {
786        tool: Arc<dyn AgentTool>,
787        args: Value,
788    },
789}
790
791struct ExecutedOutcome {
792    result: ToolResult,
793    is_error: bool,
794}
795
796pub(crate) struct FinalizedOutcome {
797    pub result: ToolResult,
798    pub is_error: bool,
799    pub terminate: bool,
800}
801
802/// Walk every registered `ToolGate` and ask each for a specific reason
803/// it denies `tool_name`. Returns the first specific reason; `None` if
804/// no gate claims responsibility (caller falls back to the active
805/// [`crate::protocol::ProtocolPolicy`], then to the core's generic
806/// hidden-tool message).
807struct GateDenial {
808    reason: String,
809    gate: &'static str,
810}
811
812async fn gate_attributed_denial(
813    tool_name: &str,
814    config: &LoopConfig,
815    messages: &[AgentMessage],
816) -> Option<GateDenial> {
817    let available_tool_names: Vec<&str> = config.tools.iter().map(|t| t.name()).collect();
818    let iteration = messages
819        .iter()
820        .filter(|m| matches!(m, AgentMessage::Assistant { .. }))
821        .count();
822    for gate in &config.plugins.tool_gate {
823        let ctx = crate::plugin::ToolGateContext {
824            iteration,
825            messages,
826            conversation_id: config.conversation_id.as_deref(),
827            available_tool_names: &available_tool_names,
828        };
829        if let Some(reason) = gate.denial_reason(tool_name, ctx).await {
830            return Some(GateDenial {
831                reason,
832                gate: gate.name(),
833            });
834        }
835    }
836    None
837}
838
839async fn prepare_call(
840    assistant: &AgentMessage,
841    assistant_content: &AssistantContent,
842    call: &ToolCall,
843    context: &AgentContext,
844    config: &LoopConfig,
845    turn_allowlist: Option<&std::collections::HashSet<String>>,
846) -> PreparedCall {
847    let Some(tool) = config.tools.get(&call.name) else {
848        return PreparedCall::Immediate(ExecutedOutcome {
849            result: ToolResult::error(format!("Tool `{}` not found", call.name)),
850            is_error: true,
851        });
852    };
853
854    // Hard-enforce per-turn `ToolGate` narrowing. The allowlist filters
855    // what schemas the model SEES; without this check, the model can
856    // hallucinate a tool name that wasn't advertised this turn and the
857    // dispatcher runs it anyway because the registry is global. That was
858    // observed in production: a model called a terminal delivery tool
859    // after a no-work narrowing had dropped it from the catalog, claimed
860    // success without doing any work, and the file it claimed to create
861    // didn't exist. Refuse here so the model sees a typed tool error and
862    // either picks an allowed tool or surfaces an unrecoverable state.
863    //
864    // Message + details are sourced in priority order:
865    //   1. a `ToolGate` that attributes the denial via `denial_reason`;
866    //   2. the active `ProtocolPolicy` (product vocabulary, if any);
867    //   3. the core's generic, vocabulary-free fallback.
868    if let Some(allowlist) = turn_allowlist {
869        if !allowlist.contains(call.name.as_str()) {
870            let attributed = gate_attributed_denial(&call.name, config, &context.messages).await;
871            let (message, details) =
872                match attributed {
873                    Some(denial) => {
874                        let details = crate::protocol::generic_hidden_tool_details(
875                            &call.name,
876                            allowlist,
877                            Some(denial.gate),
878                        );
879                        (denial.reason, details)
880                    }
881                    None => match config.protocol.hidden_tool_error(
882                        crate::protocol::HiddenToolContext {
883                            requested_tool: &call.name,
884                            allowlist,
885                            messages: &context.messages,
886                        },
887                    ) {
888                        Some(err) => (err.message, err.details),
889                        None => (
890                            crate::protocol::generic_hidden_tool_message(&call.name, allowlist),
891                            crate::protocol::generic_hidden_tool_details(
892                                &call.name, allowlist, None,
893                            ),
894                        ),
895                    },
896                };
897            let mut result = ToolResult::error(message);
898            result.details = details;
899            return PreparedCall::Immediate(ExecutedOutcome {
900                result,
901                is_error: true,
902            });
903        }
904    }
905
906    // Provider stream layers wrap a malformed-JSON tool-args buffer in
907    // a sentinel object so we can surface a clean, model-recoverable
908    // error here instead of the cryptic `invalid type: string, expected
909    // struct …` that comes from each tool's `serde_json::from_value`
910    // running over a `Value::String` fallback. Detect the sentinel
911    // before validation/dispatch.
912    if let Some((parse_err, raw)) = detect_arg_parse_error(&call.arguments) {
913        return PreparedCall::Immediate(ExecutedOutcome {
914            result: ToolResult::argument_validation_error(
915                &call.name,
916                format_arg_parse_error(&call.name, parse_err, raw),
917            ),
918            is_error: true,
919        });
920    }
921
922    let prepared_args = tool.prepare_arguments(call.arguments.clone());
923
924    if let Err(err) = tool.validate(&prepared_args) {
925        return PreparedCall::Immediate(ExecutedOutcome {
926            result: ToolResult::argument_validation_error(&call.name, err.to_string()),
927            is_error: true,
928        });
929    }
930
931    let ctx = BeforeToolCallContext {
932        assistant_message: assistant,
933        assistant_content,
934        tool_call: call,
935        args: &prepared_args,
936        messages: &context.messages,
937    };
938    for hook in &config.plugins.before_tool_call {
939        let decision = hook
940            .on_before_tool_call(BeforeToolCallContext {
941                assistant_message: ctx.assistant_message,
942                assistant_content: ctx.assistant_content,
943                tool_call: ctx.tool_call,
944                args: ctx.args,
945                messages: ctx.messages,
946            })
947            .await;
948        if decision.block {
949            let reason = decision
950                .reason
951                .unwrap_or_else(|| format!("blocked by {}", hook.name()));
952            let mut result = ToolResult::error(reason);
953            if let Some(details) = decision.details {
954                result.details = details;
955            }
956            return PreparedCall::Immediate(ExecutedOutcome {
957                result,
958                is_error: true,
959            });
960        }
961    }
962
963    PreparedCall::Prepared {
964        tool,
965        args: prepared_args,
966    }
967}
968
969async fn execute_prepared(
970    tool: &Arc<dyn AgentTool>,
971    call: &ToolCall,
972    args: Value,
973    signal: CancellationToken,
974    on_update: Box<dyn Fn(ToolResult) + Send + Sync + 'static>,
975) -> Result<ExecutedOutcome, LoopError> {
976    let (tx, mut rx) = mpsc::unbounded_channel::<ToolResult>();
977
978    // Drain partial updates concurrently so they don't backpressure the tool.
979    let mut drain_handle = tokio::spawn(async move {
980        while let Some(partial) = rx.recv().await {
981            on_update(partial);
982        }
983    });
984
985    let result = match tool.execute(&call.id, args, signal, tx).await {
986        Ok(result) => {
987            let is_error = result.is_error;
988            Ok(ExecutedOutcome { result, is_error })
989        }
990        Err(ToolError::Execution(reason)) => Ok(ExecutedOutcome {
991            result: ToolResult::error(ToolError::Execution(reason).to_string()),
992            is_error: true,
993        }),
994        Err(ToolError::Aborted) => Err(LoopError::Aborted),
995        Err(ToolError::Fatal(reason)) => Err(LoopError::ToolFatal {
996            tool: call.name.clone(),
997            reason,
998        }),
999    };
1000
1001    match timeout(TOOL_UPDATE_DRAIN_GRACE, &mut drain_handle).await {
1002        Ok(joined) => {
1003            if let Err(error) = joined {
1004                tracing::debug!(?error, "tool update dispatcher join failed");
1005            }
1006        }
1007        Err(_) => {
1008            drain_handle.abort();
1009            if let Err(error) = drain_handle.await {
1010                tracing::debug!(?error, "aborted tool update dispatcher");
1011            }
1012        }
1013    }
1014    result
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1018async fn finalize(
1019    assistant: &AgentMessage,
1020    _assistant_content: &AssistantContent,
1021    call: &ToolCall,
1022    args: &Value,
1023    mut executed: ExecutedOutcome,
1024    messages: &[AgentMessage],
1025    after_hooks: &[Arc<dyn crate::plugin::AfterToolCall>],
1026) -> FinalizedOutcome {
1027    for hook in after_hooks {
1028        let ctx = AfterToolCallContext {
1029            assistant_message: assistant,
1030            tool_call: call,
1031            args,
1032            result: &executed.result,
1033            is_error: executed.is_error,
1034            messages,
1035        };
1036        let decision = hook.on_after_tool_call(ctx).await;
1037        if let Some(new_result) = decision.result {
1038            executed.is_error = new_result.is_error;
1039            executed.result = new_result;
1040        }
1041        if let Some(mark_error) = decision.mark_error {
1042            executed.is_error = mark_error;
1043            executed.result.is_error = mark_error;
1044        }
1045        if let Some(terminate) = decision.terminate {
1046            executed.result.terminate = terminate;
1047        }
1048    }
1049
1050    FinalizedOutcome {
1051        result: executed.result,
1052        is_error: executed.is_error,
1053        terminate: false,
1054    }
1055    // Carry forward the result's own `terminate` field as the outcome's
1056    // vote. (Done after the after-hooks have had a chance to override.)
1057    .with_vote()
1058}
1059
1060impl FinalizedOutcome {
1061    fn with_vote(mut self) -> Self {
1062        self.terminate = self.result.terminate;
1063        self
1064    }
1065}
1066
1067fn outcome_to_message(call: &ToolCall, outcome: FinalizedOutcome) -> AgentMessage {
1068    let details = match outcome.result.details {
1069        serde_json::Value::Null => None,
1070        other => Some(other),
1071    };
1072    let message = AgentMessage::ToolResult {
1073        tool_call_id: call.id.clone(),
1074        tool_name: call.name.clone(),
1075        content: ToolResultContent {
1076            blocks: outcome.result.content,
1077        },
1078        is_error: outcome.is_error,
1079        // Carry the row-caption prose ("Ran `ls`.", "Wrote
1080        // `index.html` (4 KB).") into the persisted history so
1081        // history-aware plugins (working_memory_anchor, smart_context,
1082        // history_repair) see the same prose the UI renders without
1083        // having to walk content blocks past densification headers.
1084        narration: outcome.result.narration,
1085        // Carry the host-side structured payload so downstream plugins
1086        // (delivery gates, artifact dispatchers, …) can read canonical
1087        // fields without text-grepping the prose body. Stripped from
1088        // provider wire formats — the model still sees `content` only.
1089        details,
1090        timestamp: Some(now_ms()),
1091    };
1092    // Instrumentation: the post-`AfterToolCall` boundary is where any
1093    // plugin-driven `override_result` has already landed. Logging the
1094    // final content text head/tail at this point lets
1095    // `RUST_LOG=clark_agent::exec::tool_result_built=debug` captures show
1096    // what actually enters `messages` per turn — useful for triangulating
1097    // any divergence between a tool's emitted args and the user-visible
1098    // result a downstream terminal walker later selects.
1099    if let AgentMessage::ToolResult {
1100        content,
1101        is_error,
1102        tool_call_id,
1103        tool_name,
1104        ..
1105    } = &message
1106    {
1107        let plain = content.plain_text();
1108        let (head, tail) = head_tail_for_log(&plain);
1109        tracing::debug!(
1110            target: "clark_agent::exec::tool_result_built",
1111            tool_call_id = %tool_call_id,
1112            tool_name = %tool_name,
1113            is_error = *is_error,
1114            content_len = plain.len(),
1115            content_head = %head,
1116            content_tail = %tail,
1117            "outcome_to_message wrote ToolResult into messages"
1118        );
1119    }
1120    message
1121}
1122
1123const TOOL_RESULT_LOG_HEAD: usize = 200;
1124const TOOL_RESULT_LOG_TAIL: usize = 200;
1125
1126/// Head/tail snippets of a tool-result text for diagnostic logging.
1127/// Avoids dumping multi-KB tool outputs into the trace stream while
1128/// still making divergence between two snapshots of the "same" text
1129/// visible at a glance.
1130fn head_tail_for_log(text: &str) -> (String, String) {
1131    if text.len() <= TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL {
1132        return (text.to_string(), String::new());
1133    }
1134    let head_end = char_boundary_at_or_before(text, TOOL_RESULT_LOG_HEAD);
1135    let tail_start = char_boundary_at_or_after(text, text.len() - TOOL_RESULT_LOG_TAIL);
1136    (text[..head_end].to_string(), text[tail_start..].to_string())
1137}
1138
1139fn char_boundary_at_or_before(text: &str, mut idx: usize) -> usize {
1140    if idx >= text.len() {
1141        return text.len();
1142    }
1143    while idx > 0 && !text.is_char_boundary(idx) {
1144        idx -= 1;
1145    }
1146    idx
1147}
1148
1149fn char_boundary_at_or_after(text: &str, mut idx: usize) -> usize {
1150    if idx >= text.len() {
1151        return text.len();
1152    }
1153    while idx < text.len() && !text.is_char_boundary(idx) {
1154        idx += 1;
1155    }
1156    idx
1157}
1158
1159fn now_ms() -> u64 {
1160    SystemTime::now()
1161        .duration_since(UNIX_EPOCH)
1162        .map(|d| d.as_millis() as u64)
1163        .unwrap_or(0)
1164}
1165
1166async fn emit_tool_start(config: &LoopConfig, call: &ToolCall) {
1167    let event = AgentEvent::ToolExecutionStart {
1168        tool_call_id: call.id.clone(),
1169        tool_name: call.name.clone(),
1170        args: call.arguments.clone(),
1171    };
1172    config.event_sink.emit(event.clone()).await;
1173    for o in &config.plugins.event_observer {
1174        o.on_event(&event).await;
1175    }
1176}
1177
1178/// Build a human-readable, model-recoverable error for an argument
1179/// payload that failed JSON parsing in the provider stream layer. Shape
1180/// the message so the model knows (1) it was a syntax problem, not a
1181/// schema problem, (2) what raw text it produced, and (3) what to do
1182/// next. Truncate the raw payload to keep error contexts bounded.
1183fn format_arg_parse_error(tool_name: &str, parse_err: &str, raw: &str) -> String {
1184    const RAW_MAX: usize = 1024;
1185    let raw_snippet = if raw.len() > RAW_MAX {
1186        format!(
1187            "{}…<{} bytes truncated>",
1188            &raw[..RAW_MAX],
1189            raw.len() - RAW_MAX
1190        )
1191    } else {
1192        raw.to_string()
1193    };
1194    format!(
1195        "Tool `{tool_name}` arguments were not valid JSON: {parse_err}. \
1196         You sent (raw): {raw_snippet}. \
1197         Re-emit the call with a JSON object matching the tool's schema; \
1198         this is a syntax error in your tool-call arguments, not a problem \
1199         with the file or the runtime."
1200    )
1201}
1202
1203async fn emit_tool_end(config: &LoopConfig, call: &ToolCall, outcome: &FinalizedOutcome) {
1204    let event = AgentEvent::ToolExecutionEnd {
1205        tool_call_id: call.id.clone(),
1206        tool_name: call.name.clone(),
1207        result: outcome.result.clone(),
1208        is_error: outcome.is_error,
1209    };
1210    config.event_sink.emit(event.clone()).await;
1211    for o in &config.plugins.event_observer {
1212        o.on_event(&event).await;
1213    }
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218    use super::*;
1219    use crate::ToolResultBlock;
1220    use std::sync::Arc;
1221
1222    struct LimitTool {
1223        name: &'static str,
1224        counts: bool,
1225        vote_counts: bool,
1226        parallel_safe: bool,
1227    }
1228
1229    #[async_trait::async_trait]
1230    impl AgentTool for LimitTool {
1231        fn name(&self) -> &str {
1232            self.name
1233        }
1234
1235        fn description(&self) -> &str {
1236            "test tool"
1237        }
1238
1239        fn parameters_schema(&self) -> Value {
1240            json!({"type": "object"})
1241        }
1242
1243        fn counts_toward_tool_call_limit(&self) -> bool {
1244            self.counts
1245        }
1246
1247        fn parallel_safe_per_turn(&self) -> bool {
1248            self.parallel_safe
1249        }
1250
1251        fn counts_toward_termination_vote(&self) -> bool {
1252            self.vote_counts
1253        }
1254
1255        async fn execute(
1256            &self,
1257            _call_id: &str,
1258            _args: Value,
1259            _signal: CancellationToken,
1260            _update: mpsc::UnboundedSender<ToolResult>,
1261        ) -> Result<ToolResult, ToolError> {
1262            unreachable!("split tests do not execute tools")
1263        }
1264    }
1265
1266    fn registry() -> crate::tool::ToolRegistry {
1267        // Same registry the call-limit tests use plus the
1268        // termination-vote opt-out: `message_info` is advisory, the
1269        // other tools count.
1270        crate::tool::ToolRegistry::new()
1271            .with(Arc::new(LimitTool {
1272                name: "message_info",
1273                counts: false,
1274                vote_counts: false,
1275                parallel_safe: false,
1276            }))
1277            .with(Arc::new(LimitTool {
1278                name: "browser_navigate",
1279                counts: true,
1280                vote_counts: true,
1281                parallel_safe: true,
1282            }))
1283            .with(Arc::new(LimitTool {
1284                name: "browser_capture",
1285                counts: true,
1286                vote_counts: true,
1287                parallel_safe: true,
1288            }))
1289            .with(Arc::new(LimitTool {
1290                name: "browser_inspect",
1291                counts: true,
1292                vote_counts: true,
1293                parallel_safe: true,
1294            }))
1295            .with(Arc::new(LimitTool {
1296                name: "shell",
1297                counts: true,
1298                vote_counts: true,
1299                parallel_safe: false,
1300            }))
1301            .with(Arc::new(LimitTool {
1302                name: "message_result",
1303                counts: true,
1304                vote_counts: true,
1305                parallel_safe: false,
1306            }))
1307            .with(Arc::new(LimitTool {
1308                name: "message_ask",
1309                counts: true,
1310                vote_counts: true,
1311                parallel_safe: false,
1312            }))
1313            .with(Arc::new(LimitTool {
1314                name: "web_search",
1315                counts: true,
1316                vote_counts: true,
1317                parallel_safe: true,
1318            }))
1319            .with(Arc::new(LimitTool {
1320                name: "file_read",
1321                counts: true,
1322                vote_counts: true,
1323                parallel_safe: true,
1324            }))
1325    }
1326
1327    fn call(name: &str) -> ToolCall {
1328        ToolCall {
1329            id: format!("tc-{name}"),
1330            name: name.to_string(),
1331            arguments: Value::Null,
1332        }
1333    }
1334
1335    fn names(calls: &[ToolCall]) -> Vec<&str> {
1336        calls.iter().map(|call| call.name.as_str()).collect()
1337    }
1338
1339    #[test]
1340    fn progress_only_tools_do_not_starve_first_work_tool() {
1341        let registry = registry();
1342        let (executable, unexecuted, max) = split_tool_calls_for_execution(
1343            vec![call("message_info"), call("browser_navigate")],
1344            &registry,
1345            Some(1),
1346        );
1347
1348        assert_eq!(max, Some(1));
1349        assert_eq!(names(&executable), vec!["message_info", "browser_navigate"]);
1350        assert!(unexecuted.is_empty());
1351    }
1352
1353    #[test]
1354    fn extra_limit_counted_tools_still_get_synthetic_errors() {
1355        let registry = registry();
1356        let (executable, unexecuted, max) = split_tool_calls_for_execution(
1357            vec![call("message_info"), call("shell"), call("message_result")],
1358            &registry,
1359            Some(1),
1360        );
1361
1362        assert_eq!(max, Some(1));
1363        assert_eq!(names(&executable), vec!["message_info", "shell"]);
1364        assert_eq!(names(&unexecuted), vec!["message_result"]);
1365    }
1366
1367    #[test]
1368    fn parallel_safe_reads_do_not_burn_the_per_turn_cap() {
1369        // Two web_searches + one browser_navigate in a single turn:
1370        // before this change the second web_search would be dropped with
1371        // "only the first 1 call can run". After, the parallel-safe
1372        // reads execute alongside the one counted work tool.
1373        let registry = registry();
1374        let (executable, unexecuted, max) = split_tool_calls_for_execution(
1375            vec![
1376                call("web_search"),
1377                call("web_search"),
1378                call("browser_navigate"),
1379            ],
1380            &registry,
1381            Some(1),
1382        );
1383
1384        assert_eq!(max, Some(1));
1385        assert_eq!(
1386            names(&executable),
1387            vec!["web_search", "web_search", "browser_navigate"]
1388        );
1389        assert!(
1390            unexecuted.is_empty(),
1391            "unexecuted: {:?}",
1392            names(&unexecuted)
1393        );
1394    }
1395
1396    #[test]
1397    fn parallel_safe_reads_do_not_compete_with_a_write_for_the_cap() {
1398        // shell (write) still gets its single slot; the parallel-safe
1399        // reads pass through. A second shell would still be dropped.
1400        let registry = registry();
1401        let (executable, unexecuted, max) = split_tool_calls_for_execution(
1402            vec![
1403                call("file_read"),
1404                call("file_read"),
1405                call("shell"),
1406                call("shell"),
1407            ],
1408            &registry,
1409            Some(1),
1410        );
1411
1412        assert_eq!(max, Some(1));
1413        assert_eq!(names(&executable), vec!["file_read", "file_read", "shell"]);
1414        assert_eq!(names(&unexecuted), vec!["shell"]);
1415    }
1416
1417    #[test]
1418    fn browser_tools_do_not_burn_the_per_turn_cap() {
1419        // Browser tools require exclusive sandbox access, so the
1420        // executor still runs this batch sequentially. They are
1421        // nevertheless safe to admit together in one assistant turn:
1422        // a model often opens two related URLs, captures one page, and
1423        // inspects another before yielding. The per-turn cap should
1424        // not drop the later browser calls.
1425        let registry = registry();
1426        let (executable, unexecuted, max) = split_tool_calls_for_execution(
1427            vec![
1428                call("browser_navigate"),
1429                call("browser_navigate"),
1430                call("browser_capture"),
1431                call("browser_inspect"),
1432                call("shell"),
1433            ],
1434            &registry,
1435            Some(1),
1436        );
1437
1438        assert_eq!(max, Some(1));
1439        assert_eq!(
1440            names(&executable),
1441            vec![
1442                "browser_navigate",
1443                "browser_navigate",
1444                "browser_capture",
1445                "browser_inspect",
1446                "shell",
1447            ]
1448        );
1449        assert!(
1450            unexecuted.is_empty(),
1451            "unexecuted: {:?}",
1452            names(&unexecuted)
1453        );
1454    }
1455
1456    #[test]
1457    fn malformed_calls_do_not_burn_the_cap_or_preempt_real_work() {
1458        // A call that resolves to no registered tool (unknown name, or an
1459        // empty/blank name from a streaming glitch) does no real work — it
1460        // only yields a synthetic "no such tool" error in `prepare_call`. It
1461        // must NOT spend the turn's slot, or it bumps a real call into the
1462        // unexecuted bin. Regression for a production case where, under
1463        // `max_tool_calls_per_turn = 1`, an empty-name call preempted the
1464        // model's real next call.
1465        let registry = registry();
1466
1467        // Unknown name first, real counting tool second: both run; nothing deferred.
1468        let (executable, unexecuted, _) = split_tool_calls_for_execution(
1469            vec![call("missing"), call("shell")],
1470            &registry,
1471            Some(1),
1472        );
1473        assert_eq!(names(&executable), vec!["missing", "shell"]);
1474        assert!(
1475            unexecuted.is_empty(),
1476            "real work must not be preempted by an unknown name: {:?}",
1477            names(&unexecuted)
1478        );
1479
1480        // Empty name first (the prod glitch shape): the real call still runs.
1481        let (executable, unexecuted, _) =
1482            split_tool_calls_for_execution(vec![call(""), call("shell")], &registry, Some(1));
1483        assert_eq!(names(&executable), vec!["", "shell"]);
1484        assert!(
1485            unexecuted.is_empty(),
1486            "empty-name glitch must not preempt real work: {:?}",
1487            names(&unexecuted)
1488        );
1489
1490        // Two real counting tools: the cap still bites — the second is deferred.
1491        let (executable, unexecuted, _) =
1492            split_tool_calls_for_execution(vec![call("shell"), call("shell")], &registry, Some(1));
1493        assert_eq!(names(&executable), vec!["shell"]);
1494        assert_eq!(names(&unexecuted), vec!["shell"]);
1495    }
1496
1497    #[test]
1498    fn compute_batch_terminate_passes_when_only_advisory_siblings_dont_vote() {
1499        // Some models tail a terminating delivery call with a polite,
1500        // advisory sign-off call that opts out of the termination vote
1501        // (`counts_toward_termination_vote == false`). Under a strict
1502        // every-result-must-vote rule the trailing advisory call
1503        // (terminate=false) would block termination and the run would
1504        // grind to its iteration cap. With the advisory opt-out the
1505        // batch terminates on the strength of the delivery call alone.
1506        let registry = registry();
1507        let votes = [("message_result", true), ("message_info", false)];
1508        assert!(compute_batch_terminate(
1509            &registry,
1510            votes.iter().map(|(n, t)| (*n, *t))
1511        ));
1512    }
1513
1514    #[test]
1515    fn compute_batch_terminate_fails_when_any_counted_tool_did_not_vote_terminate() {
1516        let registry = registry();
1517        // `message_result` voted yes, but a real work tool (`shell`)
1518        // is still mid-flight or didn't vote — keep running.
1519        let votes = [("message_result", true), ("shell", false)];
1520        assert!(!compute_batch_terminate(
1521            &registry,
1522            votes.iter().map(|(n, t)| (*n, *t))
1523        ));
1524    }
1525
1526    #[test]
1527    fn compute_batch_terminate_returns_false_for_all_advisory_batches() {
1528        // An all-`message_info` batch must NEVER end the run; progress
1529        // notes are status, not termination, even when the model
1530        // emits several in a row.
1531        let registry = registry();
1532        let votes = [("message_info", false), ("message_info", false)];
1533        assert!(!compute_batch_terminate(
1534            &registry,
1535            votes.iter().map(|(n, t)| (*n, *t))
1536        ));
1537    }
1538
1539    #[test]
1540    fn compute_batch_terminate_returns_false_for_empty_batch() {
1541        let registry = registry();
1542        let votes: Vec<(&str, bool)> = Vec::new();
1543        assert!(!compute_batch_terminate(&registry, votes.into_iter()));
1544    }
1545
1546    #[test]
1547    fn compute_batch_terminate_treats_unknown_tools_as_counted() {
1548        // Unknown / unregistered tool names default to counted so a
1549        // stray call cannot accidentally terminate the run by being
1550        // silently classified as advisory.
1551        let registry = registry();
1552        // `message_result` voted yes, but an unknown tool emitted
1553        // `terminate=false`. Unknown counts → must not terminate.
1554        let votes = [("message_result", true), ("ghost_tool", false)];
1555        assert!(!compute_batch_terminate(
1556            &registry,
1557            votes.iter().map(|(n, t)| (*n, *t))
1558        ));
1559
1560        // And the symmetric case: an unknown tool that voted yes,
1561        // alongside `message_result` voting yes → still counted, so
1562        // the batch terminates.
1563        let votes = [("message_result", true), ("ghost_tool", true)];
1564        assert!(compute_batch_terminate(
1565            &registry,
1566            votes.iter().map(|(n, t)| (*n, *t))
1567        ));
1568    }
1569
1570    #[test]
1571    fn compute_batch_terminate_passes_when_message_ask_is_only_counted_terminator() {
1572        // Symmetric to the message_result case: message_ask (also a
1573        // terminating tool) tailed by message_info still terminates.
1574        let registry = registry();
1575        let votes = [("message_ask", true), ("message_info", false)];
1576        assert!(compute_batch_terminate(
1577            &registry,
1578            votes.iter().map(|(n, t)| (*n, *t))
1579        ));
1580    }
1581
1582    #[test]
1583    fn head_tail_for_log_returns_full_text_when_short() {
1584        // Short payloads (≤ HEAD+TAIL) round-trip in `head` with an
1585        // empty `tail` so the trace line stays compact and the
1586        // diagnostic reader doesn't have to reconstruct the full
1587        // string from two halves when there's nothing to truncate.
1588        let (head, tail) = head_tail_for_log("hello");
1589        assert_eq!(head, "hello");
1590        assert_eq!(tail, "");
1591    }
1592
1593    #[test]
1594    fn head_tail_for_log_truncates_long_text_with_head_and_tail() {
1595        let payload: String = "abc".repeat(500);
1596        assert!(payload.len() > TOOL_RESULT_LOG_HEAD + TOOL_RESULT_LOG_TAIL);
1597        let (head, tail) = head_tail_for_log(&payload);
1598        assert_eq!(head.len(), TOOL_RESULT_LOG_HEAD);
1599        assert_eq!(tail.len(), TOOL_RESULT_LOG_TAIL);
1600        // First/last bytes must come from the original — guards
1601        // against a regression where the helper accidentally re-orders
1602        // or drops the boundary characters.
1603        assert!(payload.starts_with(&head));
1604        assert!(payload.ends_with(&tail));
1605    }
1606
1607    #[test]
1608    fn head_tail_for_log_respects_utf8_char_boundaries() {
1609        // Multi-byte chars must not be split mid-codepoint or the
1610        // tracing macro would panic (and instrumentation would crash
1611        // the loop). Build a payload long enough to truncate, padded
1612        // with multi-byte chars at both boundary regions.
1613        let mid = "πλάκα".repeat(50); // each char is 2 bytes
1614        let prefix: String = "x".repeat(150);
1615        let suffix: String = "y".repeat(150);
1616        let payload = format!("{prefix}{mid}{suffix}");
1617        let (head, tail) = head_tail_for_log(&payload);
1618        // Validity assertions: both slices are valid UTF-8 (they
1619        // already are since they came from `&str`), and the boundary
1620        // is on a char boundary in the original. Round-trip check:
1621        // the head must be a prefix of payload and tail a suffix.
1622        assert!(payload.starts_with(&head));
1623        assert!(payload.ends_with(&tail));
1624        // Head capped at HEAD bytes (last char-boundary at or before).
1625        assert!(head.len() <= TOOL_RESULT_LOG_HEAD);
1626        assert!(tail.len() <= TOOL_RESULT_LOG_TAIL + 1); // +1 for boundary slack
1627    }
1628
1629    #[test]
1630    fn unexecuted_message_mentions_progress_only_calls_when_present() {
1631        let result = unexecuted_tool_call_result(3, 2, 1);
1632        let text = match result.content.first() {
1633            Some(ToolResultBlock::Text(text)) => text.text.as_str(),
1634            _ => panic!("expected text result"),
1635        };
1636
1637        assert!(text.contains("2 limit-counted tool calls"));
1638        assert!(text.contains("3 tool calls total, including progress-only calls"));
1639        assert_eq!(
1640            result
1641                .details
1642                .get("limit_counted_tool_calls")
1643                .and_then(Value::as_u64),
1644            Some(2)
1645        );
1646    }
1647}