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