Skip to main content

clark_agent/
tool.rs

1//! Tool surface.
2//!
3//! `AgentTool` is the only contract the loop knows about. Tools own their
4//! parameter schema, validation, and execution. The loop dispatches and
5//! emits events.
6//!
7//! Termination is a tool decision: a tool result with `terminate: true`
8//! ends the run if every tool in the batch agrees (unanimous). One tool
9//! wanting to stop does not stop the batch.
10
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::sync::mpsc;
17use tokio_util::sync::CancellationToken;
18
19use crate::error::{ToolError, ToolValidationError};
20pub use crate::types::ToolResultBlock;
21
22/// Loop-wide tool dispatch mode. Per-tool sequential dispatch is
23/// requested via [`AgentTool::requires_exclusive_sandbox`]; this enum
24/// is for pinning the whole loop (e.g. deterministic eval harness).
25///
26/// When a batch contains any tool with `requires_exclusive_sandbox =
27/// true`, the entire batch runs sequentially regardless of this
28/// setting.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ExecutionMode {
32    Parallel,
33    Sequential,
34}
35
36/// A tool call request emitted by the model.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct ToolCall {
39    pub id: String,
40    pub name: String,
41    pub arguments: Value,
42}
43
44/// Reserved object key used to mark an argument value that the provider
45/// stream layer could not parse as JSON. Tool args are always meant to be
46/// JSON objects; when the model emits malformed JSON (e.g. trailing
47/// comma, missing value) the provider wraps the failure in a sentinel
48/// object carrying this key plus the raw payload, so the loop can emit
49/// a structured "your JSON was malformed" error instead of the cryptic
50/// "invalid type: string, expected struct …" that comes from
51/// `serde_json::from_value` running over a `Value::String` fallback.
52pub const ARG_PARSE_ERROR_MARKER: &str = "__clark_arg_parse_error";
53
54/// Companion to [`ARG_PARSE_ERROR_MARKER`]: holds the raw JSON-ish
55/// payload the model sent, so the model can see exactly what it
56/// produced and fix the syntax in its next turn.
57pub const ARG_PARSE_RAW_MARKER: &str = "__clark_arg_raw";
58
59/// Build a [`Value`] that carries an argument-parse error for the loop
60/// to surface. Use from any provider stream layer that decoded a tool
61/// call whose `arguments` string was not valid JSON.
62pub fn arg_parse_error_value(error: impl Into<String>, raw: impl Into<String>) -> Value {
63    serde_json::json!({
64        ARG_PARSE_ERROR_MARKER: error.into(),
65        ARG_PARSE_RAW_MARKER: raw.into(),
66    })
67}
68
69/// If `args` was produced by [`arg_parse_error_value`], return
70/// `(error, raw)`. Otherwise return `None`.
71pub fn detect_arg_parse_error(args: &Value) -> Option<(&str, &str)> {
72    let obj = args.as_object()?;
73    let err = obj.get(ARG_PARSE_ERROR_MARKER)?.as_str()?;
74    let raw = obj.get(ARG_PARSE_RAW_MARKER)?.as_str()?;
75    Some((err, raw))
76}
77
78/// Result of a tool execution.
79///
80/// Always contains content blocks visible to the model. `details` is
81/// arbitrary structured metadata for logs / UI / replay; the model never
82/// sees it directly. `terminate` is the unanimous-vote signal.
83///
84/// `narration` is an optional row-caption sentence shown to the user.
85/// It is owned by the tool (or a product-level after-hook) and should
86/// be derived from typed tool state such as path, query, exit code, or
87/// byte count. The generic loop does not infer narration from private
88/// model deliberation.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ToolResult {
91    pub content: Vec<ToolResultBlock>,
92    #[serde(default, skip_serializing_if = "is_false")]
93    pub is_error: bool,
94    #[serde(default, skip_serializing_if = "Value::is_null")]
95    pub details: Value,
96    #[serde(default, skip_serializing_if = "is_false")]
97    pub terminate: bool,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub narration: Option<String>,
100}
101
102fn is_false(b: &bool) -> bool {
103    !*b
104}
105
106impl ToolResult {
107    /// Convenience: a plain-text successful result.
108    pub fn text(text: impl Into<String>) -> Self {
109        Self {
110            content: vec![ToolResultBlock::Text(crate::types::TextContent {
111                text: text.into(),
112            })],
113            is_error: false,
114            details: Value::Null,
115            terminate: false,
116            narration: None,
117        }
118    }
119
120    /// Convenience: a plain-text terminal result (vote to end the run).
121    pub fn terminal(text: impl Into<String>) -> Self {
122        Self {
123            content: vec![ToolResultBlock::Text(crate::types::TextContent {
124                text: text.into(),
125            })],
126            is_error: false,
127            details: Value::Null,
128            terminate: true,
129            narration: None,
130        }
131    }
132
133    /// Convenience: an error result. The loop treats this as a context
134    /// event, not a fatal — the model can recover.
135    pub fn error(text: impl Into<String>) -> Self {
136        Self {
137            content: vec![ToolResultBlock::Text(crate::types::TextContent {
138                text: text.into(),
139            })],
140            is_error: true,
141            details: Value::Null,
142            terminate: false,
143            narration: None,
144        }
145    }
146
147    /// Attach a one-sentence diary entry in the user's voice. Whitespace-only
148    /// input is dropped to keep the diary clean. Trims surrounding whitespace
149    /// so call sites can hand in templated multi-line strings.
150    pub fn with_narration(mut self, narration: impl Into<String>) -> Self {
151        let raw: String = narration.into();
152        let trimmed = raw.trim();
153        if !trimmed.is_empty() {
154            self.narration = Some(trimmed.to_string());
155        }
156        self
157    }
158}
159
160/// Sink the tool can use to publish partial progress while running.
161///
162/// The loop forwards each partial as `AgentEvent::ToolExecutionUpdate`.
163/// Tools call `update.send(...)` zero or more times before returning the
164/// final result.
165pub type ToolUpdateSink = mpsc::UnboundedSender<ToolResult>;
166
167/// Tool-authored context-retention hints for history transforms.
168///
169/// The core loop does not interpret these policies directly. They are
170/// narrow metadata for `ContextTransform` plugins that need to summarize
171/// or trim history without maintaining a parallel list of tool names.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ToolHistoryPolicy {
174    /// Argument whose string value identifies duplicate calls of the
175    /// same tool. Older successful results for the same value may be
176    /// replaced by a marker that points at the latest result.
177    pub dedup_arg: Option<&'static str>,
178    /// Argument to render in compact one-line summaries.
179    pub summary_arg: Option<&'static str>,
180    /// Whether old successful results are re-fetchable enough to clear
181    /// during time-based microcompaction.
182    pub compactable_result: bool,
183    /// Whether the latest successful result should be pinned near the
184    /// newest user turn as the active plan.
185    pub pins_active_plan: bool,
186}
187
188impl ToolHistoryPolicy {
189    pub const fn new() -> Self {
190        Self {
191            dedup_arg: None,
192            summary_arg: None,
193            compactable_result: false,
194            pins_active_plan: false,
195        }
196    }
197
198    pub const fn dedup_arg(mut self, arg: &'static str) -> Self {
199        self.dedup_arg = Some(arg);
200        self
201    }
202
203    pub const fn summary_arg(mut self, arg: &'static str) -> Self {
204        self.summary_arg = Some(arg);
205        self
206    }
207
208    pub const fn compactable_result(mut self) -> Self {
209        self.compactable_result = true;
210        self
211    }
212
213    pub const fn pins_active_plan(mut self) -> Self {
214        self.pins_active_plan = true;
215        self
216    }
217}
218
219impl Default for ToolHistoryPolicy {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225/// A tool the agent can call.
226///
227/// Implementations supply: name, description, JSON schema for arguments,
228/// optional argument prep + validation, and an async `execute`.
229#[async_trait]
230pub trait AgentTool: Send + Sync + 'static {
231    fn name(&self) -> &str;
232
233    fn description(&self) -> &str;
234
235    /// JSON Schema for the tool's arguments. The loop hands this verbatim
236    /// to the LLM provider.
237    fn parameters_schema(&self) -> Value;
238
239    /// Whether this tool needs exclusive access to the shared sandbox
240    /// state — a single browser/desktop session, a persistent terminal,
241    /// the workspace cwd, etc. When ANY tool in a batch declares this,
242    /// the entire batch runs sequentially.
243    ///
244    /// The canonical (and currently only) per-tool knob for forcing
245    /// sequential dispatch. If a future use case needs sequential for a
246    /// non-sandbox reason (rate-limited external API, host-process
247    /// state, etc.), introduce a more specific signal then — keep this
248    /// trait surface narrow until the case actually appears.
249    ///
250    /// For loop-wide sequential mode (e.g. deterministic eval), use
251    /// [`crate::config::AgentBuilder::default_execution_mode`] instead.
252    ///
253    /// Default: `false` (stateless / read-only tools).
254    fn requires_exclusive_sandbox(&self) -> bool {
255        false
256    }
257
258    /// Maximum size of this tool's result content (in chars) that
259    /// `ToolResultBudget` allows through to the model on subsequent
260    /// turns. `None` means "use the global default"; `Some(usize::MAX)`
261    /// means "this tool's output is too important to clip — keep
262    /// verbatim". `Some(n)` declares a tool-specific cap that overrides
263    /// the global default.
264    ///
265    /// Tools that produce large structured output the model needs to
266    /// inspect in full (publish results, full-page snapshots) should
267    /// return `Some(usize::MAX)`. Tools that produce voluminous and
268    /// re-fetchable content (shell, file_read, browser body) should
269    /// usually leave this at the default.
270    ///
271    /// Has no effect when `ToolResultBudget` isn't installed in the
272    /// loop's `ContextTransform` chain.
273    fn max_result_chars(&self) -> Option<usize> {
274        None
275    }
276
277    /// Tool-owned hints for history transforms. Defaults to no special
278    /// handling; tools that emit re-fetchable or summary-worthy results
279    /// opt in where their argument contract is defined.
280    fn history_policy(&self) -> ToolHistoryPolicy {
281        ToolHistoryPolicy::default()
282    }
283
284    /// Tool-owned identity declaration for loop-detection plugins. A
285    /// tool that dispatches on `action` / `mode` / similar declares
286    /// the discriminator here so the runtime never has to re-encode
287    /// the same fact in a separate allowlist. See
288    /// `clark_agent::tool_identity` for the contract; defaults to
289    /// "single opaque operation" which preserves the historical
290    /// fall-through behavior for tools that opt out.
291    fn identity_policy(&self) -> crate::tool_identity::ToolIdentityPolicy {
292        crate::tool_identity::ToolIdentityPolicy::default()
293    }
294
295    /// Whether a non-fatal failure of this tool in a parallel batch
296    /// should cancel its still-running sibling tools. Default `false`
297    /// — failures are isolated and siblings run to completion. Tools
298    /// where one failure makes parallel work meaningless (a `shell`
299    /// step that gates `npm test`, a delegated build whose result the
300    /// siblings depend on) opt in by overriding this to `true`.
301    ///
302    /// Cancelled siblings produce a `ToolResult` with
303    /// `is_error: true, content: "aborted because sibling 'X' failed"`
304    /// — they remain context events the next turn can react to, never
305    /// `LoopError`s. Sibling-abort therefore never ends the run on its
306    /// own; the unanimous-vote termination rule is preserved.
307    ///
308    /// Cancellation is cooperative: tools must check
309    /// `signal.is_cancelled()` (or wrap blocking work in `select!`) to
310    /// honor the cancel promptly. Subprocess-based tools should rely
311    /// on `Drop` killing the child when the future is dropped.
312    fn aborts_siblings_on_error(&self) -> bool {
313        false
314    }
315
316    /// Whether this tool consumes a slot from
317    /// `LoopConfig::max_tool_calls_per_turn`.
318    ///
319    /// Default `true`: tools do work, ask/answer, mutate state, or otherwise
320    /// participate in the loop's bounded execution budget. Lightweight
321    /// progress-only signals can opt out so they do not starve the next real
322    /// action when a provider emits a status note and a work tool in the same
323    /// assistant turn.
324    fn counts_toward_tool_call_limit(&self) -> bool {
325        true
326    }
327
328    /// Whether this tool is safe to invoke multiple times in a single
329    /// assistant turn alongside other tool calls.
330    ///
331    /// Default `false`: tools serialize at the configured cap so writes
332    /// and stateful operations stay sequenced. Read-only / idempotent
333    /// tools (web search, file read, grep, glob, snapshots) override to
334    /// `true` so a provider that batches several independent lookups in
335    /// one turn does not get N-1 of them rejected with a "only the first
336    /// call can run" error. Parallel-safe tools still execute one at a
337    /// time on the runtime side; they just do not contend for the
338    /// per-turn cap.
339    fn parallel_safe_per_turn(&self) -> bool {
340        false
341    }
342
343    /// Whether this tool's `terminate` vote is included in the
344    /// unanimous-vote tally that decides whether the batch ends the
345    /// run.
346    ///
347    /// Default `true`: every tool's vote counts. The batch terminates
348    /// only when *every* tool that opts in voted `terminate: true`.
349    ///
350    /// Lightweight status-only tools (progress notes, hidden journals)
351    /// override to `false`. The runtime then ignores their vote
352    /// entirely — they are neither a "yes" nor a "no" — so a model that
353    /// emits a terminating delivery call alongside an advisory status
354    /// call in the same batch can still terminate. An all-advisory batch
355    /// (no tool with this flag set to `true` voted yes) does NOT
356    /// terminate, preserving the contract that progress notes never end
357    /// a run on their own.
358    fn counts_toward_termination_vote(&self) -> bool {
359        true
360    }
361
362    /// Optional argument normalization before validation. Pure function.
363    /// Default: identity.
364    fn prepare_arguments(&self, args: Value) -> Value {
365        args
366    }
367
368    /// Validate prepared arguments. Default: succeed.
369    /// Implement for tools that have action-specific required fields not
370    /// expressible in pure JSON Schema.
371    fn validate(&self, _args: &Value) -> Result<(), ToolValidationError> {
372        Ok(())
373    }
374
375    /// Execute the tool. Returns the final result.
376    ///
377    /// `update` may be used to publish partial progress while running.
378    /// Honor `signal` for cancellation.
379    async fn execute(
380        &self,
381        call_id: &str,
382        args: Value,
383        signal: CancellationToken,
384        update: ToolUpdateSink,
385    ) -> Result<ToolResult, ToolError>;
386}
387
388// ---------------------------------------------------------------------------
389// TypedAgentTool — the canonical authoring surface for tools whose argument
390// shape is a typed Rust struct or enum.
391//
392// One source of truth (`Args`) drives both the wire schema (generated
393// via schemars) and the runtime parse — no hand-written JSON Schema,
394// no opportunity for drift. New tool authors implement `TypedAgentTool` and
395// get the `AgentTool` impl for free via the blanket below.
396//
397// Tag-dispatched tools (a single tool with several modes selected by a
398// discriminator field, e.g. `edit(op="insert"|"replace"|"delete")`) use a
399// `#[serde(tag = "...")]` enum as `Args`; serde routes the discriminator
400// natively, so the "unknown field `op`" failure mode that motivated this
401// trait can
402// no longer happen.
403// ---------------------------------------------------------------------------
404
405/// Implement this for tools whose argument shape is a typed Rust
406/// struct/enum. The blanket `AgentTool` impl below derives
407/// `parameters_schema` from `Args` via schemars and centralizes the
408/// `Value → Args` parse path. New tools should implement `TypedAgentTool`,
409/// not `AgentTool` directly; existing tools are migrated incrementally.
410#[async_trait]
411pub trait TypedAgentTool: Send + Sync + 'static {
412    /// The argument shape. The wire schema is generated from this
413    /// type; the dispatcher parses incoming `Value` into `Args` once
414    /// and hands the typed value to `run`.
415    type Args: serde::de::DeserializeOwned + schemars::JsonSchema + Send + 'static;
416
417    fn name(&self) -> &str;
418    fn description(&self) -> &str;
419
420    /// Whether this tool needs exclusive sandbox access. Default false.
421    fn requires_exclusive_sandbox(&self) -> bool {
422        false
423    }
424
425    /// Per-tool max-result-chars override for `ToolResultBudget`.
426    /// Default `None` (use the global default).
427    fn max_result_chars(&self) -> Option<usize> {
428        None
429    }
430
431    /// Tool-owned hints for history transforms. Defaults to no special
432    /// handling.
433    fn history_policy(&self) -> ToolHistoryPolicy {
434        ToolHistoryPolicy::default()
435    }
436
437    /// Tool-owned identity declaration for loop-detection plugins.
438    /// Mirrors `AgentTool::identity_policy`; defaults to "single
439    /// opaque operation". See `clark_agent::tool_identity`.
440    fn identity_policy(&self) -> crate::tool_identity::ToolIdentityPolicy {
441        crate::tool_identity::ToolIdentityPolicy::default()
442    }
443
444    /// Whether a non-fatal failure of this tool in a parallel batch
445    /// cancels still-running siblings. Default false.
446    fn aborts_siblings_on_error(&self) -> bool {
447        false
448    }
449
450    /// Whether this tool consumes a slot from
451    /// `LoopConfig::max_tool_calls_per_turn`. Default true.
452    fn counts_toward_tool_call_limit(&self) -> bool {
453        true
454    }
455
456    /// Whether this tool is safe to invoke multiple times in a single
457    /// assistant turn alongside other tool calls. Default `false`. See
458    /// the corresponding `AgentTool::parallel_safe_per_turn` docstring.
459    fn parallel_safe_per_turn(&self) -> bool {
460        false
461    }
462
463    /// Whether this tool's `terminate` vote counts in the
464    /// unanimous-vote tally. Default `true`. Status-only progress
465    /// tools opt out by returning `false`; see the corresponding
466    /// `AgentTool::counts_toward_termination_vote` docstring.
467    fn counts_toward_termination_vote(&self) -> bool {
468        true
469    }
470
471    /// Optional pre-deserialization normalization of raw args. Pure
472    /// function. Runs before `strip_top_level_nulls` and
473    /// `coerce_string_scalars_at_top_level` so tool-specific
474    /// canonicalization (e.g. inferring a tagged-enum's `action`
475    /// discriminator from variant-unique fields) lands first.
476    ///
477    /// Default: identity. See [`AgentTool::prepare_arguments`].
478    fn prepare_arguments(&self, args: Value) -> Value {
479        args
480    }
481
482    /// Execute the tool with already-parsed typed args.
483    async fn run(
484        &self,
485        call_id: &str,
486        args: Self::Args,
487        signal: CancellationToken,
488        update: ToolUpdateSink,
489    ) -> Result<ToolResult, ToolError>;
490}
491
492/// Blanket impl: every `TypedAgentTool` is automatically an `AgentTool`.
493///
494/// The schema is built with `inline_subschemas = true` because some
495/// strict tool-schema validators (Azure, certain OpenAI-compatible
496/// proxies) reject `$ref` chains in tool schemas. Inlining keeps the
497/// generated JSON Schema flat and provider-portable.
498#[async_trait]
499impl<T: TypedAgentTool> AgentTool for T {
500    fn name(&self) -> &str {
501        TypedAgentTool::name(self)
502    }
503
504    fn description(&self) -> &str {
505        TypedAgentTool::description(self)
506    }
507
508    fn parameters_schema(&self) -> Value {
509        let settings = schemars::gen::SchemaSettings::draft07().with(|s| {
510            s.inline_subschemas = true;
511        });
512        let generator = settings.into_generator();
513        let schema = generator.into_root_schema_for::<T::Args>();
514        let value = serde_json::to_value(schema).expect("typed-tool schema serializes");
515        let mut value = flatten_tagged_oneof_schema(value);
516        normalize_strict_validator_quirks(&mut value);
517        value
518    }
519
520    fn requires_exclusive_sandbox(&self) -> bool {
521        TypedAgentTool::requires_exclusive_sandbox(self)
522    }
523
524    fn max_result_chars(&self) -> Option<usize> {
525        TypedAgentTool::max_result_chars(self)
526    }
527
528    fn history_policy(&self) -> ToolHistoryPolicy {
529        TypedAgentTool::history_policy(self)
530    }
531
532    fn identity_policy(&self) -> crate::tool_identity::ToolIdentityPolicy {
533        TypedAgentTool::identity_policy(self)
534    }
535
536    fn aborts_siblings_on_error(&self) -> bool {
537        TypedAgentTool::aborts_siblings_on_error(self)
538    }
539
540    fn counts_toward_tool_call_limit(&self) -> bool {
541        TypedAgentTool::counts_toward_tool_call_limit(self)
542    }
543
544    fn parallel_safe_per_turn(&self) -> bool {
545        TypedAgentTool::parallel_safe_per_turn(self)
546    }
547
548    fn counts_toward_termination_vote(&self) -> bool {
549        TypedAgentTool::counts_toward_termination_vote(self)
550    }
551
552    fn prepare_arguments(&self, args: Value) -> Value {
553        TypedAgentTool::prepare_arguments(self, args)
554    }
555
556    async fn execute(
557        &self,
558        call_id: &str,
559        args: Value,
560        signal: CancellationToken,
561        update: ToolUpdateSink,
562    ) -> Result<ToolResult, ToolError> {
563        // Strip top-level `null` fields before deserializing.
564        // Tagged-enum tools have variants with `deny_unknown_fields`,
565        // but `flatten_tagged_oneof_schema` exposes EVERY variant's
566        // fields as a union. Some models populate non-applicable fields
567        // with `null` ("being helpful" — submitting all schema-known
568        // fields). Without this strip, the chosen variant rejects with
569        // `unknown field` and a turn is wasted — observed across whole
570        // eval suites for some providers on tagged-enum tools.
571        // Nulls carry no semantic value at this boundary — they are
572        // either "field not set" or "inapplicable to the chosen
573        // variant"; both collapse to "drop the field and let the
574        // variant's `serde(default)` apply".
575        //
576        // Run tool-specific `prepare_arguments` FIRST so per-tool
577        // canonicalization (e.g. inferring a tagged-enum's `action`
578        // discriminator from variant-unique fields like `url`) lands
579        // before the generic null-strip and string-scalar coercion.
580        let prepared = AgentTool::prepare_arguments(self, args);
581        let stripped = strip_top_level_nulls(prepared);
582        // Coerce string-encoded scalars (integers, numbers, booleans)
583        // to their declared types BEFORE serde validation runs. Some
584        // providers (notably the "auto-when-forced" class) emit
585        // tool-call arguments where every value is a JSON string —
586        // `{"max_iterations": "50"}` instead of `{"max_iterations": 50}`.
587        // The strict serde path rejects every such call, wasting a turn
588        // per field; coercion converts the obvious case in-place using
589        // the tool's own schema as the source of truth.
590        let schema = AgentTool::parameters_schema(self);
591        let coerced = coerce_string_scalars_at_top_level(stripped, &schema);
592        let parsed: T::Args = match serde_json::from_value(coerced) {
593            Ok(v) => v,
594            Err(e) => {
595                return Ok(ToolResult::error(format!(
596                    "{}: invalid arguments: {}",
597                    TypedAgentTool::name(self),
598                    enrich_arg_parse_error_message(&e),
599                )));
600            }
601        };
602        TypedAgentTool::run(self, call_id, parsed, signal, update).await
603    }
604}
605
606/// Convert top-level string-encoded scalars to their JSON-Schema-declared
607/// types when the conversion is unambiguous. Walks `value` (which must be
608/// an object) and, for each property whose schema declares a single scalar
609/// type (`integer`, `number`, `boolean`), parses the corresponding string
610/// value in place. Leaves arrays, objects, nested oneOf branches, and
611/// fields with a non-string current value untouched — those go through
612/// the strict serde path unchanged. Conservative by design: any
613/// ambiguity (multi-type schemas, untyped properties, unparseable
614/// strings) preserves the original value so the strict validator still
615/// catches genuinely-malformed args.
616fn coerce_string_scalars_at_top_level(value: Value, schema: &Value) -> Value {
617    let Value::Object(mut map) = value else {
618        return value;
619    };
620    let Some(properties) = schema.get("properties").and_then(Value::as_object) else {
621        return Value::Object(map);
622    };
623    for (key, val) in map.iter_mut() {
624        let Some(prop_schema) = properties.get(key) else {
625            continue;
626        };
627        coerce_one_scalar_in_place(val, prop_schema);
628    }
629    Value::Object(map)
630}
631
632fn coerce_one_scalar_in_place(value: &mut Value, prop_schema: &Value) {
633    let Some(text) = value.as_str() else {
634        return;
635    };
636    let Some(target) = scalar_target_from_schema(prop_schema) else {
637        return;
638    };
639    match target {
640        ScalarTarget::Integer => {
641            let trimmed = text.trim();
642            if let Ok(n) = trimmed.parse::<i64>() {
643                *value = Value::Number(serde_json::Number::from(n));
644            } else if let Ok(n) = trimmed.parse::<u64>() {
645                *value = Value::Number(serde_json::Number::from(n));
646            }
647        }
648        ScalarTarget::Number => {
649            let trimmed = text.trim();
650            if let Ok(n) = trimmed.parse::<f64>() {
651                if let Some(num) = serde_json::Number::from_f64(n) {
652                    *value = Value::Number(num);
653                }
654            }
655        }
656        ScalarTarget::Boolean => match text.trim() {
657            "true" | "True" | "TRUE" => *value = Value::Bool(true),
658            "false" | "False" | "FALSE" => *value = Value::Bool(false),
659            _ => {}
660        },
661    }
662}
663
664#[derive(Debug, Clone, Copy)]
665enum ScalarTarget {
666    Integer,
667    Number,
668    Boolean,
669}
670
671fn scalar_target_from_schema(prop_schema: &Value) -> Option<ScalarTarget> {
672    let type_field = prop_schema.get("type")?;
673    let single = match type_field {
674        Value::String(s) => Some(s.as_str()),
675        // Optional-shaped schemas often render as ["T", "null"]; pick the
676        // non-null entry. Anything wider (e.g. ["string", "integer"]) is
677        // genuinely ambiguous — skip and let the strict validator decide.
678        Value::Array(arr) => {
679            let non_null: Vec<&str> = arr
680                .iter()
681                .filter_map(|v| v.as_str())
682                .filter(|s| *s != "null")
683                .collect();
684            if non_null.len() == 1 {
685                Some(non_null[0])
686            } else {
687                None
688            }
689        }
690        _ => None,
691    }?;
692    match single {
693        "integer" => Some(ScalarTarget::Integer),
694        "number" => Some(ScalarTarget::Number),
695        "boolean" => Some(ScalarTarget::Boolean),
696        _ => None,
697    }
698}
699
700/// Append a self-correcting hint to a serde-deserialize error message
701/// when the failure pattern is something a model can fix on the next
702/// turn (e.g. "string \"50\", expected usize" → "Did you mean the
703/// integer 50?"). The base error text is preserved verbatim so the
704/// existing format stays diffable; the hint is suffixed after a period.
705fn enrich_arg_parse_error_message(err: &serde_json::Error) -> String {
706    let raw = err.to_string();
707    match arg_parse_hint(&raw) {
708        Some(hint) => format!("{raw}. {hint}"),
709        None => raw,
710    }
711}
712
713fn arg_parse_hint(raw: &str) -> Option<String> {
714    let value = extract_invalid_string_value(raw)?;
715    if expects_integer(raw) {
716        let parsed: i128 = value.trim().parse().ok()?;
717        return Some(format!(
718            "Did you mean the integer {parsed}? Resend without quotes."
719        ));
720    }
721    if expects_number(raw) {
722        let parsed: f64 = value.trim().parse().ok()?;
723        return Some(format!(
724            "Did you mean the number {parsed}? Resend without quotes."
725        ));
726    }
727    if expects_boolean(raw) {
728        return match value.trim() {
729            "true" | "True" | "TRUE" => Some(
730                "Did you mean true? Resend as a boolean literal (lowercase, no quotes)."
731                    .to_string(),
732            ),
733            "false" | "False" | "FALSE" => Some(
734                "Did you mean false? Resend as a boolean literal (lowercase, no quotes)."
735                    .to_string(),
736            ),
737            _ => None,
738        };
739    }
740    if expects_sequence(raw) {
741        return Some(
742            "Expected a JSON array (e.g. `[{...}, {...}]`); the field cannot be a string. \
743             Resend the value as an array of structured items, not a string of XML-like markup."
744                .to_string(),
745        );
746    }
747    None
748}
749
750fn extract_invalid_string_value(raw: &str) -> Option<&str> {
751    // Serde's `invalid type` errors quote the offending value as
752    // `string "X"`. Locate the inner content without pulling in a regex
753    // dependency; bail on the first malformed shape.
754    let start = raw.find("string \"")? + "string \"".len();
755    let rest = &raw[start..];
756    let end = rest.find('\"')?;
757    Some(&rest[..end])
758}
759
760fn expects_integer(raw: &str) -> bool {
761    raw.contains("expected usize")
762        || raw.contains("expected isize")
763        || raw.contains("expected u8")
764        || raw.contains("expected u16")
765        || raw.contains("expected u32")
766        || raw.contains("expected u64")
767        || raw.contains("expected i8")
768        || raw.contains("expected i16")
769        || raw.contains("expected i32")
770        || raw.contains("expected i64")
771        || raw.contains("expected integer")
772}
773
774fn expects_number(raw: &str) -> bool {
775    raw.contains("expected f32")
776        || raw.contains("expected f64")
777        || raw.contains("expected floating point")
778}
779
780fn expects_boolean(raw: &str) -> bool {
781    raw.contains("expected a boolean") || raw.contains("expected bool")
782}
783
784fn expects_sequence(raw: &str) -> bool {
785    raw.contains("expected a sequence") || raw.contains("expected an array")
786}
787
788fn strip_top_level_nulls(value: Value) -> Value {
789    match value {
790        Value::Object(map) => {
791            Value::Object(map.into_iter().filter(|(_, v)| !v.is_null()).collect())
792        }
793        other => other,
794    }
795}
796
797/// Flatten a top-level `oneOf` of tag-discriminated objects into a
798/// single object schema with the discriminator promoted to a top-level
799/// `enum` field. Schemars naturally emits `oneOf` for
800/// `#[serde(tag = "kind")]` enums, but several strict tool-schema
801/// validators (Azure's, certain OpenAI-compatible proxies, observed
802/// xAI/Grok behaviour where the model silently refuses to call the
803/// tool) reject schemas that have `oneOf` at the top level. The
804/// runtime contract is unchanged — serde still routes by the
805/// discriminator on the input side and `deny_unknown_fields` still
806/// catches per-variant typos on the parse side. The wire schema just
807/// presents a flatter union to the model.
808///
809/// Inputs that aren't a tag-dispatched `oneOf` (single struct, true
810/// untagged unions) pass through unchanged.
811fn flatten_tagged_oneof_schema(schema: Value) -> Value {
812    let Value::Object(mut root) = schema else {
813        return schema;
814    };
815    let Some(Value::Array(variants)) = root.remove("oneOf") else {
816        // No oneOf → already a flat schema (single-struct tool).
817        if !root.is_empty() {
818            return Value::Object(root);
819        }
820        return Value::Null;
821    };
822
823    // Per-variant info captured during the walk so we can annotate
824    // each merged property with which variants own it. Strict
825    // validators (Azure) reject top-level `allOf` / `oneOf` / `anyOf`
826    // / `enum` / `not` even when paired with `type: "object"`, so we
827    // can't carry per-variant constraints structurally at the root.
828    // Instead, encode the per-variant applicability into each
829    // property's `description` ("applies when kind in: [document]"),
830    // derived from the same tagged-enum walk. The model reads it; the
831    // validator doesn't care about description text.
832    struct VariantSpec {
833        tag_value_str: Option<String>,
834        own_field_names: Vec<String>,
835    }
836
837    let mut discriminator: Option<String> = None;
838    let mut variant_specs: Vec<VariantSpec> = Vec::with_capacity(variants.len());
839    let mut merged_props = serde_json::Map::new();
840    let mut required_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
841    let mut tag_in_required = true;
842    // tag-values for the discriminator's enum; preserved as Value to
843    // support non-string tags even though only strings are common.
844    let mut tag_values: Vec<Value> = Vec::with_capacity(variants.len());
845
846    for variant in &variants {
847        let Some(obj) = variant.as_object() else {
848            return reassemble_oneof(root, variants);
849        };
850        let Some(Value::Object(props)) = obj.get("properties").cloned() else {
851            return reassemble_oneof(root, variants);
852        };
853        // Find the variant's tag property: a property whose schema is
854        // a single-element `enum` of strings.
855        let mut variant_tag: Option<(String, Value)> = None;
856        for (name, prop) in props.iter() {
857            let Some(prop_obj) = prop.as_object() else {
858                continue;
859            };
860            let Some(Value::Array(enum_values)) = prop_obj.get("enum").cloned() else {
861                continue;
862            };
863            if enum_values.len() == 1 {
864                variant_tag = Some((name.clone(), enum_values.into_iter().next().unwrap()));
865                break;
866            }
867        }
868        let Some((tag_name, tag_value)) = variant_tag else {
869            return reassemble_oneof(root, variants);
870        };
871        match &discriminator {
872            None => discriminator = Some(tag_name.clone()),
873            Some(existing) if existing == &tag_name => {}
874            Some(_) => return reassemble_oneof(root, variants),
875        }
876        tag_values.push(tag_value.clone());
877
878        // Merge non-tag properties (union) and record this variant's
879        // own field names for the description annotation pass below.
880        let mut own_field_names = Vec::new();
881        for (name, prop_schema) in props.iter() {
882            if name == &tag_name {
883                continue;
884            }
885            merged_props
886                .entry(name.clone())
887                .or_insert_with(|| prop_schema.clone());
888            own_field_names.push(name.clone());
889        }
890
891        // Tag is required at the outer level only if every variant
892        // requires it. Per-variant non-tag required keys can't be
893        // hoisted to the outer schema — they'd break sibling variants.
894        // Serde's `deny_unknown_fields` still enforces them per-variant
895        // at parse time.
896        let mut tag_required_here = false;
897        if let Some(Value::Array(req)) = obj.get("required") {
898            for r in req {
899                if let Some(s) = r.as_str() {
900                    if s == tag_name {
901                        tag_required_here = true;
902                    }
903                }
904            }
905        }
906        if !tag_required_here {
907            tag_in_required = false;
908        }
909
910        variant_specs.push(VariantSpec {
911            tag_value_str: tag_value.as_str().map(str::to_string),
912            own_field_names,
913        });
914    }
915
916    let Some(discriminator) = discriminator else {
917        return reassemble_oneof(root, variants);
918    };
919
920    // Annotate each merged property with the variants that own it.
921    // Skip when the property is owned by every variant (no narrowing
922    // information to add) and when any variant tag isn't a plain
923    // string (annotation requires a stable label).
924    let total_variants = variant_specs.len();
925    let all_tags_are_strings = variant_specs.iter().all(|s| s.tag_value_str.is_some());
926    if all_tags_are_strings && total_variants > 1 {
927        let mut owners: std::collections::BTreeMap<String, Vec<String>> =
928            std::collections::BTreeMap::new();
929        for spec in &variant_specs {
930            let tag_label = spec.tag_value_str.clone().unwrap_or_default();
931            for field in &spec.own_field_names {
932                owners
933                    .entry(field.clone())
934                    .or_default()
935                    .push(tag_label.clone());
936            }
937        }
938        for (field, mut variant_tags) in owners {
939            if variant_tags.len() == total_variants {
940                continue;
941            }
942            variant_tags.sort();
943            variant_tags.dedup();
944            let suffix = format!(
945                " (applies when {discriminator} in: [{}])",
946                variant_tags.join(", ")
947            );
948            if let Some(Value::Object(prop_map)) = merged_props.get_mut(&field) {
949                let new_desc = match prop_map.get("description") {
950                    Some(Value::String(existing)) if !existing.is_empty() => {
951                        format!("{existing}{suffix}")
952                    }
953                    _ => suffix.trim_start().to_string(),
954                };
955                prop_map.insert("description".to_string(), Value::String(new_desc));
956            }
957        }
958    }
959
960    // Build the discriminator property with the union of tag values.
961    // It must be first in insertion order: models emit JSON
962    // autoregressively, so variant-specific fields need to be
963    // conditioned on the already-emitted discriminator instead of the
964    // other way around.
965    let mut tag_prop = serde_json::Map::new();
966    tag_prop.insert("type".to_string(), Value::String("string".to_string()));
967    tag_prop.insert("enum".to_string(), Value::Array(tag_values));
968    let mut ordered_props = serde_json::Map::new();
969    ordered_props.insert(discriminator.clone(), Value::Object(tag_prop));
970    for (name, schema) in merged_props {
971        ordered_props.insert(name, schema);
972    }
973    if tag_in_required {
974        required_set.insert(discriminator);
975    }
976
977    let mut out = serde_json::Map::new();
978    if let Some(desc) = root.remove("description") {
979        out.insert("description".to_string(), desc);
980    }
981    if let Some(schema) = root.remove("$schema") {
982        out.insert("$schema".to_string(), schema);
983    }
984    out.insert("type".to_string(), Value::String("object".to_string()));
985    out.insert("properties".to_string(), Value::Object(ordered_props));
986    if !required_set.is_empty() {
987        out.insert(
988            "required".to_string(),
989            Value::Array(required_set.into_iter().map(Value::String).collect()),
990        );
991    }
992    Value::Object(out)
993}
994
995fn reassemble_oneof(mut root: serde_json::Map<String, Value>, variants: Vec<Value>) -> Value {
996    root.insert("oneOf".to_string(), Value::Array(variants));
997    Value::Object(root)
998}
999
1000/// Coerce schemars output into shapes that strict tool-schema
1001/// validators (Azure's, OpenAI's via Azure proxy, several
1002/// OpenAI-compatible upstreams) accept. The current quirks list:
1003///
1004/// 1. `items: true` (boolean schema, valid in JSON Schema 2020-12 and
1005///    schemars's default for `Vec<Value>` cells) → rewrite to
1006///    `items: {}` (empty-object schema, draft-07 compatible). Azure
1007///    rejects boolean schemas with
1008///    `array schema items is not an object`.
1009///
1010/// Walks the tree once, mutating in place. Idempotent.
1011fn normalize_strict_validator_quirks(value: &mut Value) {
1012    match value {
1013        Value::Object(map) => {
1014            // Coerce `items: true` to `items: {}`.
1015            if let Some(items) = map.get_mut("items") {
1016                if matches!(items, Value::Bool(true)) {
1017                    *items = Value::Object(serde_json::Map::new());
1018                }
1019            }
1020            for v in map.values_mut() {
1021                normalize_strict_validator_quirks(v);
1022            }
1023        }
1024        Value::Array(arr) => {
1025            for v in arr {
1026                normalize_strict_validator_quirks(v);
1027            }
1028        }
1029        _ => {}
1030    }
1031}
1032
1033/// Registry of available tools, keyed by name.
1034#[derive(Default, Clone)]
1035pub struct ToolRegistry {
1036    tools: HashMap<String, Arc<dyn AgentTool>>,
1037    order: Vec<String>,
1038}
1039
1040impl ToolRegistry {
1041    pub fn new() -> Self {
1042        Self::default()
1043    }
1044
1045    pub fn with(mut self, tool: Arc<dyn AgentTool>) -> Self {
1046        self.register(tool);
1047        self
1048    }
1049
1050    pub fn register(&mut self, tool: Arc<dyn AgentTool>) {
1051        let name = tool.name().to_string();
1052        if !self.tools.contains_key(&name) {
1053            self.order.push(name.clone());
1054        }
1055        self.tools.insert(name, tool);
1056    }
1057
1058    pub fn get(&self, name: &str) -> Option<Arc<dyn AgentTool>> {
1059        self.tools.get(name).cloned()
1060    }
1061
1062    pub fn history_policy(&self, name: &str) -> ToolHistoryPolicy {
1063        self.tools
1064            .get(name)
1065            .map(|tool| tool.history_policy())
1066            .unwrap_or_default()
1067    }
1068
1069    /// Identity declaration for one tool — used by the semantic-loop
1070    /// detector and other plugins that need to recognize repeats.
1071    /// Returns the default ("single opaque operation") for unknown
1072    /// names; the detector treats that as the historical
1073    /// fall-through and falls back to canonical-JSON identity.
1074    pub fn identity_policy(&self, name: &str) -> crate::tool_identity::ToolIdentityPolicy {
1075        self.tools
1076            .get(name)
1077            .map(|tool| tool.identity_policy())
1078            .unwrap_or_default()
1079    }
1080
1081    /// Snapshot of identity policies for every registered tool. The
1082    /// `SemanticLoopDetector` (and any future plugin that needs the
1083    /// same identity contract) takes one of these at construction so
1084    /// it does not have to hold an `Arc<ToolRegistry>`.
1085    pub fn identity_policies(
1086        &self,
1087    ) -> std::collections::HashMap<String, crate::tool_identity::ToolIdentityPolicy> {
1088        self.tools
1089            .iter()
1090            .map(|(name, tool)| (name.clone(), tool.identity_policy()))
1091            .collect()
1092    }
1093
1094    pub fn names(&self) -> Vec<&str> {
1095        self.order.iter().map(String::as_str).collect()
1096    }
1097
1098    pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn AgentTool>> {
1099        self.order.iter().filter_map(|name| self.tools.get(name))
1100    }
1101
1102    pub fn is_empty(&self) -> bool {
1103        self.tools.is_empty()
1104    }
1105
1106    pub fn len(&self) -> usize {
1107        self.tools.len()
1108    }
1109}
1110
1111impl std::fmt::Debug for ToolRegistry {
1112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1113        f.debug_struct("ToolRegistry")
1114            .field("tools", &self.order)
1115            .finish()
1116    }
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122    use crate::types::TextContent;
1123    use schemars::JsonSchema;
1124    use serde::Deserialize;
1125
1126    // ---- flatten_tagged_oneof_schema ----------------------------------
1127
1128    #[derive(Deserialize, JsonSchema)]
1129    #[serde(deny_unknown_fields)]
1130    #[allow(dead_code)]
1131    struct DocVariantArgs {
1132        filename: String,
1133        #[serde(default)]
1134        title: Option<String>,
1135    }
1136
1137    #[derive(Deserialize, JsonSchema)]
1138    #[serde(deny_unknown_fields)]
1139    #[allow(dead_code)]
1140    struct ExcelVariantArgs {
1141        filename: String,
1142        #[serde(default)]
1143        rows: Vec<Vec<serde_json::Value>>,
1144    }
1145
1146    #[derive(Deserialize, JsonSchema)]
1147    #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1148    #[allow(dead_code)]
1149    enum ExampleArgs {
1150        Document(DocVariantArgs),
1151        Excel(ExcelVariantArgs),
1152    }
1153
1154    fn build_example_schema() -> Value {
1155        let settings = schemars::gen::SchemaSettings::draft07().with(|s| {
1156            s.inline_subschemas = true;
1157        });
1158        let g = settings.into_generator();
1159        let s = g.into_root_schema_for::<ExampleArgs>();
1160        let raw = serde_json::to_value(s).unwrap();
1161        flatten_tagged_oneof_schema(raw)
1162    }
1163
1164    #[derive(Deserialize, JsonSchema)]
1165    #[serde(deny_unknown_fields)]
1166    #[allow(dead_code)]
1167    struct NonAlphabeticOrderCanaryArgs {
1168        zeta_selector: String,
1169        alpha_payload: String,
1170        middle_payload: String,
1171    }
1172
1173    #[test]
1174    fn schema_runtime_preserves_insertion_order_for_tool_objects() {
1175        // This is the low-level wire invariant behind tool-schema field
1176        // geometry. The model emits arguments autoregressively in schema
1177        // order, so serde_json objects must serialize in insertion order,
1178        // not alphabetical map order.
1179        let mut object = serde_json::Map::new();
1180        object.insert("zeta_selector".to_string(), Value::String("z".to_string()));
1181        object.insert("alpha_payload".to_string(), Value::String("a".to_string()));
1182        object.insert("middle_payload".to_string(), Value::String("m".to_string()));
1183
1184        let keys = object.keys().map(String::as_str).collect::<Vec<_>>();
1185        assert_eq!(
1186            keys,
1187            ["zeta_selector", "alpha_payload", "middle_payload"],
1188            "serde_json::Map must keep insertion order; losing this breaks \
1189             model-facing tool-schema property order"
1190        );
1191
1192        let serialized = serde_json::to_string(&Value::Object(object)).unwrap();
1193        assert_eq!(
1194            serialized, r#"{"zeta_selector":"z","alpha_payload":"a","middle_payload":"m"}"#,
1195            "schema JSON serialization must preserve object insertion order"
1196        );
1197    }
1198
1199    #[test]
1200    fn schemars_preserves_declared_struct_order_for_tool_args() {
1201        // Workspace schemars must keep Rust declaration order in
1202        // JSON-Schema `properties`; otherwise any Args type with a
1203        // discriminator, planning field, or thinking field silently
1204        // changes the order in which the model writes arguments.
1205        let settings = schemars::gen::SchemaSettings::draft07().with(|s| {
1206            s.inline_subschemas = true;
1207        });
1208        let schema = serde_json::to_value(
1209            settings
1210                .into_generator()
1211                .into_root_schema_for::<NonAlphabeticOrderCanaryArgs>(),
1212        )
1213        .expect("schema serializes");
1214        let props = schema
1215            .get("properties")
1216            .and_then(Value::as_object)
1217            .expect("schema must expose properties");
1218        let order = props.keys().map(String::as_str).collect::<Vec<_>>();
1219        assert_eq!(
1220            order,
1221            ["zeta_selector", "alpha_payload", "middle_payload"],
1222            "schemars must emit Args fields in declaration order for \
1223             autoregressive tool-call conditioning"
1224        );
1225    }
1226
1227    #[test]
1228    fn flatten_tagged_oneof_produces_flat_object_schema() {
1229        let s = build_example_schema();
1230        assert_eq!(s.get("type").and_then(Value::as_str), Some("object"));
1231        // Top level no longer has oneOf — that's the whole point.
1232        assert!(s.get("oneOf").is_none());
1233        // Discriminator is at the top level with the union of variant
1234        // tag values.
1235        let kind_prop = s.pointer("/properties/kind").expect("kind property");
1236        assert_eq!(
1237            kind_prop.get("type").and_then(Value::as_str),
1238            Some("string")
1239        );
1240        let kind_enum = kind_prop
1241            .get("enum")
1242            .and_then(Value::as_array)
1243            .expect("enum");
1244        let mut kinds: Vec<&str> = kind_enum.iter().filter_map(Value::as_str).collect();
1245        kinds.sort();
1246        assert_eq!(kinds, vec!["document", "excel"]);
1247        let props = s
1248            .get("properties")
1249            .and_then(Value::as_object)
1250            .expect("properties");
1251        let order: Vec<&str> = props.keys().map(String::as_str).collect();
1252        assert_eq!(
1253            order.first().copied(),
1254            Some("kind"),
1255            "discriminator must be emitted before payload fields so \
1256             variant-specific keys are conditioned on the selected kind"
1257        );
1258        // Per-variant fields are merged into one properties object.
1259        assert!(s.pointer("/properties/filename").is_some());
1260        assert!(s.pointer("/properties/title").is_some());
1261        assert!(s.pointer("/properties/rows").is_some());
1262        // `kind` is required at the top level.
1263        let req = s
1264            .get("required")
1265            .and_then(Value::as_array)
1266            .expect("required");
1267        assert!(req.iter().any(|v| v.as_str() == Some("kind")));
1268    }
1269
1270    #[test]
1271    fn flatten_tagged_oneof_annotates_variant_specific_property_descriptions() {
1272        // Strict tool-schema validators (Azure et al.) reject top-level
1273        // `allOf` / `oneOf` / `anyOf` / `enum` / `not` even with
1274        // `type: "object"` present (see openai_basic.rs). So we can't
1275        // express per-variant constraints structurally at the root.
1276        //
1277        // Fallback: annotate each merged property's `description` with
1278        // the variant tags that own it ("applies when <discriminator>
1279        // in: [...]"). The model reads descriptions; the validator
1280        // ignores them. Derived purely from the same `JsonSchema`-
1281        // derived enum walk — single source of truth.
1282        //
1283        // Regression target: weaker models mixed sibling-variant
1284        // fields until the schema told them which fields belong to
1285        // which discriminator value.
1286        let s = build_example_schema();
1287
1288        // Properties present in BOTH variants (`filename`) get no
1289        // narrowing suffix — they apply universally.
1290        let filename_desc = s
1291            .pointer("/properties/filename/description")
1292            .and_then(Value::as_str)
1293            .unwrap_or_default();
1294        assert!(
1295            !filename_desc.contains("applies when kind in"),
1296            "shared property `filename` must NOT carry a narrowing \
1297             suffix; got: {filename_desc:?}"
1298        );
1299
1300        // Properties owned by only one variant get a narrowing suffix.
1301        let title_desc = s
1302            .pointer("/properties/title/description")
1303            .and_then(Value::as_str)
1304            .expect("title description present");
1305        assert!(
1306            title_desc.contains("applies when kind in: [document]"),
1307            "Document-only `title` must declare its variant scope; \
1308             got: {title_desc:?}"
1309        );
1310        let rows_desc = s
1311            .pointer("/properties/rows/description")
1312            .and_then(Value::as_str)
1313            .expect("rows description present");
1314        assert!(
1315            rows_desc.contains("applies when kind in: [excel]"),
1316            "Excel-only `rows` must declare its variant scope; \
1317             got: {rows_desc:?}"
1318        );
1319
1320        // No top-level `allOf` / `oneOf` / `anyOf` — the validator
1321        // rejects them.
1322        assert!(
1323            s.get("allOf").is_none(),
1324            "top-level allOf would be rejected by Azure's tool validator"
1325        );
1326        assert!(s.get("oneOf").is_none());
1327        assert!(s.get("anyOf").is_none());
1328    }
1329
1330    #[test]
1331    fn normalize_strict_quirks_rewrites_items_true_to_empty_object() {
1332        // Regression: schemars emits `items: true` for `Vec<Value>`
1333        // cells. Azure's tool-schema validator rejects boolean
1334        // schemas with `array schema items is not an object`,
1335        // failing every nano (Azure-routed) call. The normalizer
1336        // walks the tree and coerces `items: true` to `items: {}`.
1337        let mut schema = serde_json::json!({
1338            "type": "object",
1339            "properties": {
1340                "rows": {
1341                    "type": "array",
1342                    "items": {
1343                        "type": "array",
1344                        "items": true
1345                    }
1346                }
1347            }
1348        });
1349        normalize_strict_validator_quirks(&mut schema);
1350        assert_eq!(
1351            schema.pointer("/properties/rows/items/items"),
1352            Some(&serde_json::json!({})),
1353        );
1354    }
1355
1356    #[test]
1357    fn strip_top_level_nulls_removes_inapplicable_variant_fields() {
1358        // Regression: weaker models submit EVERY field from EVERY
1359        // tagged-enum variant, with `null` for the non-applicable
1360        // ones, alongside the chosen discriminator. The chosen variant
1361        // has `deny_unknown_fields` and rejected with unknown
1362        // sibling fields. Stripping top-level nulls before
1363        // deserializing collapses these to missing fields and lets
1364        // `serde(default)` apply.
1365        let model_payload = serde_json::json!({
1366            "action": "run",
1367            "command": "echo hi",
1368            "workdir": "/home/user/workspace",
1369            // Sibling-variant fields the model populated with null:
1370            "code": null,
1371            "interpreter": null,
1372            "ext": null,
1373            "exec_dir": null,
1374            "max_token": null,
1375            "truncate_from": null,
1376            "run_id": null,
1377            "after_seq": null,
1378            "max_events": null,
1379            "timeout_s": null,
1380            "timeout_ms": null,
1381            "terminal": null,
1382            "force": null,
1383            // Plus a real value to confirm only nulls are dropped.
1384            "timeout_secs": 60,
1385        });
1386        let stripped = strip_top_level_nulls(model_payload);
1387        let obj = stripped.as_object().expect("object");
1388        // Nulls gone.
1389        assert!(!obj.contains_key("code"));
1390        assert!(!obj.contains_key("ext"));
1391        assert!(!obj.contains_key("max_token"));
1392        assert!(!obj.contains_key("force"));
1393        // Real values preserved.
1394        assert_eq!(obj.get("action").and_then(Value::as_str), Some("run"));
1395        assert_eq!(obj.get("command").and_then(Value::as_str), Some("echo hi"));
1396        assert_eq!(obj.get("timeout_secs").and_then(Value::as_i64), Some(60));
1397    }
1398
1399    #[test]
1400    fn strip_top_level_nulls_passes_through_non_object_values() {
1401        // Defensive: tool args at the top level should always be
1402        // objects, but the helper must not panic on the off-chance
1403        // a transport hands us a primitive.
1404        assert_eq!(
1405            strip_top_level_nulls(serde_json::json!("text")),
1406            serde_json::json!("text")
1407        );
1408        assert_eq!(strip_top_level_nulls(Value::Null), Value::Null);
1409    }
1410
1411    // ---- string-encoded-scalar coercion -------------------------------
1412    //
1413    // Some providers (notably the "auto-when-forced" class) emit tool
1414    // arguments as JSON strings for fields the schema declares as
1415    // integers, booleans, or numbers — e.g. `max_iterations: "50"`,
1416    // `num_results: "10"`, `full_page: "True"`, `full_page: "true"` —
1417    // each a wasted turn under strict serde. The coercion helpers
1418    // normalize the dominant cases against the tool's own JSON Schema;
1419    // ambiguous cases are left to the strict path.
1420
1421    fn make_schema(properties: Value) -> Value {
1422        serde_json::json!({
1423            "type": "object",
1424            "properties": properties,
1425        })
1426    }
1427
1428    #[test]
1429    fn coerce_string_to_integer_when_schema_says_integer() {
1430        let schema = make_schema(serde_json::json!({
1431            "max_iterations": {"type": "integer"},
1432        }));
1433        let coerced = coerce_string_scalars_at_top_level(
1434            serde_json::json!({"max_iterations": "50"}),
1435            &schema,
1436        );
1437        assert_eq!(coerced, serde_json::json!({"max_iterations": 50}));
1438    }
1439
1440    #[test]
1441    fn coerce_string_to_integer_handles_negative_and_whitespace() {
1442        let schema = make_schema(serde_json::json!({
1443            "offset": {"type": "integer"},
1444            "limit": {"type": "integer"},
1445        }));
1446        let coerced = coerce_string_scalars_at_top_level(
1447            serde_json::json!({"offset": "-7", "limit": "  42  "}),
1448            &schema,
1449        );
1450        assert_eq!(coerced, serde_json::json!({"offset": -7, "limit": 42}));
1451    }
1452
1453    #[test]
1454    fn coerce_string_to_boolean_for_each_case_variant() {
1455        let schema = make_schema(serde_json::json!({
1456            "full_page": {"type": "boolean"},
1457            "headless": {"type": "boolean"},
1458            "verbose": {"type": "boolean"},
1459            "untouched": {"type": "boolean"},
1460        }));
1461        let coerced = coerce_string_scalars_at_top_level(
1462            serde_json::json!({
1463                "full_page": "true",
1464                "headless": "True",
1465                "verbose": "FALSE",
1466                "untouched": "maybe",
1467            }),
1468            &schema,
1469        );
1470        // Recognised forms become bools; gibberish stays a string so the
1471        // strict validator still rejects with a useful error.
1472        assert_eq!(coerced["full_page"], serde_json::json!(true));
1473        assert_eq!(coerced["headless"], serde_json::json!(true));
1474        assert_eq!(coerced["verbose"], serde_json::json!(false));
1475        assert_eq!(coerced["untouched"], serde_json::json!("maybe"));
1476    }
1477
1478    #[test]
1479    fn coerce_string_to_number_for_float_schema() {
1480        let schema = make_schema(serde_json::json!({
1481            "temperature": {"type": "number"},
1482        }));
1483        let coerced =
1484            coerce_string_scalars_at_top_level(serde_json::json!({"temperature": "0.7"}), &schema);
1485        // f64 → Number round-trips through serde_json::Number::from_f64.
1486        let n = coerced["temperature"].as_f64().expect("number");
1487        assert!((n - 0.7).abs() < 1e-9);
1488    }
1489
1490    #[test]
1491    fn coerce_leaves_string_fields_alone() {
1492        let schema = make_schema(serde_json::json!({
1493            "query": {"type": "string"},
1494            "count": {"type": "integer"},
1495        }));
1496        let coerced = coerce_string_scalars_at_top_level(
1497            serde_json::json!({"query": "50", "count": "50"}),
1498            &schema,
1499        );
1500        // The string-typed field must NOT be turned into a number even
1501        // though "50" parses cleanly — schema is the source of truth.
1502        assert_eq!(coerced["query"], serde_json::json!("50"));
1503        assert_eq!(coerced["count"], serde_json::json!(50));
1504    }
1505
1506    #[test]
1507    fn coerce_leaves_unparseable_strings_alone() {
1508        let schema = make_schema(serde_json::json!({
1509            "max_iterations": {"type": "integer"},
1510        }));
1511        let coerced = coerce_string_scalars_at_top_level(
1512            serde_json::json!({"max_iterations": "fifty"}),
1513            &schema,
1514        );
1515        // Unparseable values pass through so the strict serde path
1516        // produces the canonical "invalid type" error rather than us
1517        // silently dropping the value.
1518        assert_eq!(coerced, serde_json::json!({"max_iterations": "fifty"}));
1519    }
1520
1521    #[test]
1522    fn coerce_treats_nullable_integer_as_integer() {
1523        // `Option<usize>` renders as `{"type": ["integer", "null"]}`.
1524        // The non-null branch is unambiguous, so coercion still applies.
1525        let schema = make_schema(serde_json::json!({
1526            "max_iterations": {"type": ["integer", "null"]},
1527        }));
1528        let coerced = coerce_string_scalars_at_top_level(
1529            serde_json::json!({"max_iterations": "20"}),
1530            &schema,
1531        );
1532        assert_eq!(coerced, serde_json::json!({"max_iterations": 20}));
1533    }
1534
1535    #[test]
1536    fn coerce_skips_ambiguous_multi_type_schemas() {
1537        // If the schema genuinely accepts both string and integer, leave
1538        // the value alone — coercion would discard the model's chosen
1539        // representation. Multi-type schemas wider than `[T, null]` are
1540        // ambiguous.
1541        let schema = make_schema(serde_json::json!({
1542            "value": {"type": ["integer", "string"]},
1543        }));
1544        let coerced =
1545            coerce_string_scalars_at_top_level(serde_json::json!({"value": "42"}), &schema);
1546        assert_eq!(coerced, serde_json::json!({"value": "42"}));
1547    }
1548
1549    #[test]
1550    fn coerce_passes_through_object_without_properties() {
1551        // No schema info → no coercion. Mirrors the safe path for tools
1552        // that ship a schema without explicit `properties` (e.g. when
1553        // the args type is `serde_json::Value`).
1554        let schema = serde_json::json!({"type": "object"});
1555        let coerced = coerce_string_scalars_at_top_level(serde_json::json!({"x": "50"}), &schema);
1556        assert_eq!(coerced, serde_json::json!({"x": "50"}));
1557    }
1558
1559    // ---- arg-parse-error enrichment -----------------------------------
1560
1561    fn hint_for(json: Value, expected_target: &str) -> Option<String> {
1562        // Drive serde with a real schema mismatch so the helper sees a
1563        // genuine `serde_json::Error`, not a hand-written string. Skip
1564        // the coercion pass on purpose — we want the strict-path error.
1565        #[derive(Debug, Deserialize, JsonSchema)]
1566        #[allow(dead_code)]
1567        struct UsizeField {
1568            n: usize,
1569        }
1570        #[derive(Debug, Deserialize, JsonSchema)]
1571        #[allow(dead_code)]
1572        struct BoolField {
1573            b: bool,
1574        }
1575        #[derive(Debug, Deserialize, JsonSchema)]
1576        #[allow(dead_code)]
1577        struct VecField {
1578            items: Vec<serde_json::Value>,
1579        }
1580        let raw = match expected_target {
1581            "usize" => serde_json::from_value::<UsizeField>(json).unwrap_err(),
1582            "bool" => serde_json::from_value::<BoolField>(json).unwrap_err(),
1583            "sequence" => serde_json::from_value::<VecField>(json).unwrap_err(),
1584            _ => panic!("unknown target {expected_target}"),
1585        };
1586        Some(enrich_arg_parse_error_message(&raw))
1587    }
1588
1589    #[test]
1590    fn enrich_appends_integer_hint_for_string_encoded_int() {
1591        let msg = hint_for(serde_json::json!({"n": "50"}), "usize").unwrap();
1592        assert!(
1593            msg.contains("Did you mean the integer 50"),
1594            "expected integer hint, got: {msg}"
1595        );
1596        assert!(msg.contains("Resend without quotes"));
1597    }
1598
1599    #[test]
1600    fn enrich_appends_boolean_hint_for_string_encoded_bool() {
1601        let msg = hint_for(serde_json::json!({"b": "True"}), "bool").unwrap();
1602        assert!(
1603            msg.contains("Did you mean true"),
1604            "expected boolean hint, got: {msg}"
1605        );
1606    }
1607
1608    #[test]
1609    fn enrich_appends_sequence_hint_for_string_in_array_slot() {
1610        let xml_soup = "\n<ref>{\"kind\":\"file\",\"path\":\"x.md\"}</ref></artifact></file_write>";
1611        let msg = hint_for(serde_json::json!({"items": xml_soup}), "sequence").unwrap();
1612        assert!(
1613            msg.contains("Expected a JSON array"),
1614            "expected sequence hint, got: {msg}"
1615        );
1616    }
1617
1618    #[test]
1619    fn enrich_passes_through_unrecognised_errors_unchanged() {
1620        // Errors that don't match a known pattern (e.g. missing field)
1621        // must surface verbatim; making up a hint would mislead.
1622        #[derive(Debug, Deserialize, JsonSchema)]
1623        #[allow(dead_code)]
1624        struct R {
1625            n: usize,
1626        }
1627        let err = serde_json::from_value::<R>(serde_json::json!({})).unwrap_err();
1628        let raw = err.to_string();
1629        let enriched = enrich_arg_parse_error_message(&err);
1630        assert_eq!(enriched, raw);
1631    }
1632
1633    #[test]
1634    fn flatten_tagged_oneof_passes_through_single_struct_schemas() {
1635        // A non-enum schema (the EchoTool shape) has no oneOf and
1636        // should pass through verbatim.
1637        let raw = serde_json::json!({
1638            "type": "object",
1639            "properties": {"text": {"type": "string"}},
1640            "required": ["text"],
1641        });
1642        let out = flatten_tagged_oneof_schema(raw.clone());
1643        assert_eq!(out, raw);
1644    }
1645
1646    struct EchoTool;
1647
1648    #[async_trait]
1649    impl AgentTool for EchoTool {
1650        fn name(&self) -> &str {
1651            "echo"
1652        }
1653
1654        fn description(&self) -> &str {
1655            "Echo arguments back as text"
1656        }
1657
1658        fn parameters_schema(&self) -> Value {
1659            serde_json::json!({
1660                "type": "object",
1661                "properties": {"text": {"type": "string"}},
1662                "required": ["text"]
1663            })
1664        }
1665
1666        async fn execute(
1667            &self,
1668            _call_id: &str,
1669            args: Value,
1670            _signal: CancellationToken,
1671            _update: ToolUpdateSink,
1672        ) -> Result<ToolResult, ToolError> {
1673            let text = args
1674                .get("text")
1675                .and_then(Value::as_str)
1676                .unwrap_or("")
1677                .to_string();
1678            Ok(ToolResult {
1679                content: vec![ToolResultBlock::Text(TextContent { text })],
1680                is_error: false,
1681                details: Value::Null,
1682                terminate: false,
1683                narration: None,
1684            })
1685        }
1686    }
1687
1688    #[test]
1689    fn registry_lookup() {
1690        let registry = ToolRegistry::new().with(Arc::new(EchoTool));
1691        assert!(registry.get("echo").is_some());
1692        assert!(registry.get("missing").is_none());
1693        assert_eq!(registry.len(), 1);
1694    }
1695
1696    struct NamedTool(&'static str);
1697
1698    #[async_trait]
1699    impl AgentTool for NamedTool {
1700        fn name(&self) -> &str {
1701            self.0
1702        }
1703
1704        fn description(&self) -> &str {
1705            "named"
1706        }
1707
1708        fn parameters_schema(&self) -> Value {
1709            serde_json::json!({"type": "object", "properties": {}})
1710        }
1711
1712        async fn execute(
1713            &self,
1714            _call_id: &str,
1715            _args: Value,
1716            _signal: CancellationToken,
1717            _update: ToolUpdateSink,
1718        ) -> Result<ToolResult, ToolError> {
1719            Ok(ToolResult::text("ok"))
1720        }
1721    }
1722
1723    #[test]
1724    fn registry_preserves_registration_order() {
1725        let mut registry = ToolRegistry::new()
1726            .with(Arc::new(NamedTool("message_result")))
1727            .with(Arc::new(NamedTool("message_ask")))
1728            .with(Arc::new(NamedTool("plan")));
1729
1730        registry.register(Arc::new(NamedTool("message_result")));
1731
1732        assert_eq!(
1733            registry.names(),
1734            vec!["message_result", "message_ask", "plan"]
1735        );
1736        assert_eq!(
1737            registry.iter().map(|tool| tool.name()).collect::<Vec<_>>(),
1738            vec!["message_result", "message_ask", "plan"]
1739        );
1740    }
1741
1742    #[tokio::test]
1743    async fn echo_tool_executes() {
1744        let tool = EchoTool;
1745        let (tx, _rx) = mpsc::unbounded_channel();
1746        let result = tool
1747            .execute(
1748                "call_1",
1749                serde_json::json!({"text": "hi"}),
1750                CancellationToken::new(),
1751                tx,
1752            )
1753            .await
1754            .unwrap();
1755        let ToolResultBlock::Text(t) = &result.content[0] else {
1756            panic!("expected text")
1757        };
1758        assert_eq!(t.text, "hi");
1759    }
1760
1761    // ---- end-to-end execute path ----------------------------------------
1762    //
1763    // The blanket impl `AgentTool::execute` for `TypedAgentTool` invokes
1764    // (1) strip_top_level_nulls, (2) coerce_string_scalars_at_top_level,
1765    // (3) serde_json::from_value, (4) enrich_arg_parse_error_message.
1766    // Exercise the full path with a tool whose args mix the scalar types
1767    // some providers routinely encode as strings.
1768
1769    #[derive(Debug, Deserialize, JsonSchema)]
1770    #[serde(deny_unknown_fields)]
1771    struct CoercibleArgs {
1772        max_iterations: usize,
1773        full_page: bool,
1774        temperature: f32,
1775        label: String,
1776    }
1777
1778    struct CoercibleTool;
1779
1780    #[async_trait]
1781    impl TypedAgentTool for CoercibleTool {
1782        type Args = CoercibleArgs;
1783        fn name(&self) -> &str {
1784            "coercible"
1785        }
1786        fn description(&self) -> &str {
1787            "fixture"
1788        }
1789        async fn run(
1790            &self,
1791            _call_id: &str,
1792            args: Self::Args,
1793            _signal: CancellationToken,
1794            _update: ToolUpdateSink,
1795        ) -> Result<ToolResult, ToolError> {
1796            // Echo the parsed values so the test can assert coercion happened.
1797            Ok(ToolResult::text(format!(
1798                "max_iterations={} full_page={} temperature={} label={}",
1799                args.max_iterations, args.full_page, args.temperature, args.label
1800            )))
1801        }
1802    }
1803
1804    #[tokio::test]
1805    async fn execute_coerces_string_encoded_scalars_end_to_end() {
1806        // The four shapes seen in practice — strings where the schema
1807        // declares integers, booleans, or floats. Each must pass the
1808        // validator after coercion and reach the tool's `run` with the
1809        // typed value.
1810        let tool = CoercibleTool;
1811        let (tx, _rx) = mpsc::unbounded_channel();
1812        let result = AgentTool::execute(
1813            &tool,
1814            "call_1",
1815            serde_json::json!({
1816                "max_iterations": "50",
1817                "full_page": "True",
1818                "temperature": "0.7",
1819                "label": "actual string",
1820            }),
1821            CancellationToken::new(),
1822            tx,
1823        )
1824        .await
1825        .unwrap();
1826        let ToolResultBlock::Text(t) = &result.content[0] else {
1827            panic!("expected text result");
1828        };
1829        assert!(
1830            t.text.contains("max_iterations=50"),
1831            "integer coercion missing: {}",
1832            t.text
1833        );
1834        assert!(
1835            t.text.contains("full_page=true"),
1836            "boolean coercion missing: {}",
1837            t.text
1838        );
1839        assert!(
1840            t.text.contains("temperature=0.7"),
1841            "float coercion missing: {}",
1842            t.text
1843        );
1844        assert!(
1845            t.text.contains("label=actual string"),
1846            "string field must NOT be coerced: {}",
1847            t.text
1848        );
1849        assert!(!result.is_error, "execute must succeed after coercion");
1850    }
1851
1852    #[tokio::test]
1853    async fn execute_appends_self_correcting_hint_on_unrecoverable_string_int() {
1854        // The string "fifty" cannot be coerced to an integer; the
1855        // validator rejects, and the runtime appends a hint only when
1856        // it's accurate. Here the hint must NOT claim "Did you mean
1857        // the integer fifty" — there is no such number — so the
1858        // enrichment should pass through.
1859        let tool = CoercibleTool;
1860        let (tx, _rx) = mpsc::unbounded_channel();
1861        let result = AgentTool::execute(
1862            &tool,
1863            "call_2",
1864            serde_json::json!({
1865                "max_iterations": "fifty",
1866                "full_page": true,
1867                "temperature": 0.1,
1868                "label": "x",
1869            }),
1870            CancellationToken::new(),
1871            tx,
1872        )
1873        .await
1874        .unwrap();
1875        assert!(result.is_error, "expected validator rejection");
1876        let ToolResultBlock::Text(t) = &result.content[0] else {
1877            panic!("expected text result");
1878        };
1879        assert!(
1880            t.text.starts_with("coercible: invalid arguments:"),
1881            "preserve canonical error prefix: {}",
1882            t.text
1883        );
1884        assert!(
1885            !t.text.contains("Did you mean the integer fifty"),
1886            "must not invent a hint when the value cannot parse: {}",
1887            t.text
1888        );
1889    }
1890
1891    // Fixture for prepare_arguments wiring — mimics a tagged-enum
1892    // tool like `browser_navigate` where the discriminator field
1893    // must be present but can be inferred from a variant-unique
1894    // field.
1895    #[derive(Debug, Deserialize, JsonSchema)]
1896    #[serde(tag = "action", rename_all = "snake_case")]
1897    enum TaggedArgs {
1898        Open { url: String },
1899        Reload {},
1900    }
1901
1902    struct TaggedTool;
1903
1904    #[async_trait]
1905    impl TypedAgentTool for TaggedTool {
1906        type Args = TaggedArgs;
1907        fn name(&self) -> &str {
1908            "tagged_fixture"
1909        }
1910        fn description(&self) -> &str {
1911            "fixture"
1912        }
1913        fn prepare_arguments(&self, args: Value) -> Value {
1914            // Same inference shape as BrowserNavigateTool's real
1915            // override: if `action` is missing and `url` is present,
1916            // assume `open`.
1917            let Value::Object(mut obj) = args else {
1918                return args;
1919            };
1920            if !obj.contains_key("action") && obj.contains_key("url") {
1921                obj.insert("action".to_string(), Value::String("open".to_string()));
1922            }
1923            Value::Object(obj)
1924        }
1925        async fn run(
1926            &self,
1927            _call_id: &str,
1928            args: Self::Args,
1929            _signal: CancellationToken,
1930            _update: ToolUpdateSink,
1931        ) -> Result<ToolResult, ToolError> {
1932            let label = match args {
1933                TaggedArgs::Open { url } => format!("open:{url}"),
1934                TaggedArgs::Reload {} => "reload".to_string(),
1935            };
1936            Ok(ToolResult::text(label))
1937        }
1938    }
1939
1940    #[tokio::test]
1941    async fn execute_runs_prepare_arguments_before_typed_deser() {
1942        // Reproduces the dominant `browser_navigate` failure: the
1943        // model emits a tagged-enum call without the discriminator.
1944        // With `prepare_arguments` wired into the blanket execute,
1945        // the missing `action` is inferred from `url` and the call
1946        // reaches `run` as the `Open` variant.
1947        let tool = TaggedTool;
1948        let (tx, _rx) = mpsc::unbounded_channel();
1949        let result = AgentTool::execute(
1950            &tool,
1951            "call_1",
1952            serde_json::json!({"url": "https://example.com"}),
1953            CancellationToken::new(),
1954            tx,
1955        )
1956        .await
1957        .unwrap();
1958        let ToolResultBlock::Text(t) = &result.content[0] else {
1959            panic!("expected text result");
1960        };
1961        assert!(
1962            !result.is_error,
1963            "execute must succeed after action inference"
1964        );
1965        assert_eq!(t.text, "open:https://example.com");
1966    }
1967
1968    #[tokio::test]
1969    async fn execute_prepare_arguments_does_not_override_explicit_action() {
1970        let tool = TaggedTool;
1971        let (tx, _rx) = mpsc::unbounded_channel();
1972        let result = AgentTool::execute(
1973            &tool,
1974            "call_2",
1975            serde_json::json!({"action": "reload"}),
1976            CancellationToken::new(),
1977            tx,
1978        )
1979        .await
1980        .unwrap();
1981        let ToolResultBlock::Text(t) = &result.content[0] else {
1982            panic!("expected text result");
1983        };
1984        assert!(!result.is_error);
1985        assert_eq!(t.text, "reload");
1986    }
1987}