Skip to main content

af_agent/
tool.rs

1//! Agent tools + registry. Port of the reusable shape of `agent_core/tools/`.
2//!
3//! A tool exposes a JSON-Schema interface to the model and an async
4//! implementation. The registry resolves the name the model emits in a
5//! `tool_call` to an implementation, exposes the LLM-facing specs, and runs
6//! the call. Domain tools live in a product
7//! crate and register here.
8
9use af_context::{RunId, SessionId, ToolCallId};
10use std::collections::HashMap;
11use std::fmt;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::Arc;
16use std::time::Instant;
17
18use async_trait::async_trait;
19use serde_json::Value;
20use sha2::{Digest, Sha256};
21
22use af_llm::Tool as LlmTool;
23
24/// Who may call a tool.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ToolSurface {
27    /// Advertised to the model.
28    Llm,
29    /// Only the host or platform.
30    Chassis,
31}
32
33/// Whether a tool may run alongside others in one step.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ToolConcurrency {
36    /// May run in parallel.
37    Concurrent,
38    /// Runs alone; acts as a barrier.
39    Exclusive,
40}
41
42/// User-visible risk class used by the conversation approval policy.
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum ToolRiskClass {
46    /// Missing metadata; execution must fail closed.
47    #[default]
48    Unclassified,
49    /// No external or durable mutation.
50    Read,
51    /// Reversible or bounded mutation permitted by `low_risk_auto`.
52    LowRiskWrite,
53    /// Mutation that always requires explicit confirmation.
54    ConfirmedWrite,
55    /// Money movement or equivalent funds authority.
56    Funds,
57}
58
59impl ToolRiskClass {
60    /// Stable snake_case manifest value.
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::Unclassified => "unclassified",
64            Self::Read => "read",
65            Self::LowRiskWrite => "low_risk_write",
66            Self::ConfirmedWrite => "confirmed_write",
67            Self::Funds => "funds",
68        }
69    }
70}
71
72/// Component that performs a tool's effect after runtime authorization.
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ToolExecutionOwner {
76    /// Missing metadata; execution must fail closed.
77    #[default]
78    Unclassified,
79    /// The shared Factory runtime owns the operation.
80    Runtime,
81    /// The consuming product host owns the operation.
82    ProductHost,
83}
84
85impl ToolExecutionOwner {
86    /// Stable snake_case manifest value.
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Self::Unclassified => "unclassified",
90            Self::Runtime => "runtime",
91            Self::ProductHost => "product_host",
92        }
93    }
94}
95
96/// Static execution metadata for a tool.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct ToolMeta {
99    /// Who may call it.
100    pub surface: ToolSurface,
101    /// Provider-neutral cost units settled for this operation.
102    pub cost_units: u64,
103    /// Per-call timeout.
104    pub timeout_secs: u64,
105    /// Whether the tool is part of the platform core (never narrowed away).
106    pub core: bool,
107    /// Concurrency class.
108    pub concurrency: ToolConcurrency,
109    /// Explicit approval risk classification.
110    pub risk_class: ToolRiskClass,
111    /// Component that owns execution after authorization.
112    pub execution_owner: ToolExecutionOwner,
113    /// Mutating tools must cross the runtime's normal confirmation hook even
114    /// when a profile forgot to list them explicitly.
115    pub requires_confirmation: bool,
116}
117
118/// Hierarchical cancellation flag. Cancelling a parent cancels every child;
119/// [`cancelled`](Self::cancelled) resolves without polling.
120#[derive(Debug, Clone, Default)]
121pub struct CancellationToken(Arc<CancellationState>);
122
123#[derive(Debug, Default)]
124struct CancellationState {
125    cancelled: AtomicBool,
126    notify: tokio::sync::Notify,
127    parent: Option<CancellationToken>,
128}
129
130impl CancellationToken {
131    /// Cancel this token and every child.
132    pub fn cancel(&self) {
133        self.0.cancelled.store(true, Ordering::Release);
134        self.0.notify.notify_waiters();
135    }
136    /// Whether this token or an ancestor was cancelled.
137    pub fn is_cancelled(&self) -> bool {
138        self.0.cancelled.load(Ordering::Acquire)
139            || self
140                .0
141                .parent
142                .as_ref()
143                .is_some_and(CancellationToken::is_cancelled)
144    }
145    /// A child token cancelled with its parent.
146    pub fn child(&self) -> Self {
147        Self(Arc::new(CancellationState {
148            cancelled: AtomicBool::new(false),
149            notify: tokio::sync::Notify::new(),
150            parent: Some(self.clone()),
151        }))
152    }
153    /// Resolves once this token or any ancestor is cancelled.
154    pub fn cancelled(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
155        Box::pin(async move {
156            let notified = self.0.notify.notified();
157            tokio::pin!(notified);
158            notified.as_mut().enable();
159            if self.is_cancelled() {
160                return;
161            }
162            match &self.0.parent {
163                Some(parent) => tokio::select! {
164                    _ = notified => {}
165                    _ = parent.cancelled() => {}
166                },
167                None => notified.await,
168            }
169        })
170    }
171}
172
173/// Identity, position and controls for one tool execution.
174#[derive(Debug, Clone)]
175pub struct ToolExecutionContext {
176    /// The originating request.
177    pub request: af_context::RequestContext,
178    /// Session this record belongs to.
179    pub session_id: SessionId,
180    /// Run this record belongs to.
181    pub run_id: RunId,
182    /// 1-based step number inside the Turn.
183    pub step: u32,
184    /// Tool call this record refers to.
185    pub call_id: ToolCallId,
186    /// Sequence of the Session event this was derived from.
187    pub source_event_seq: u64,
188    /// Explicit approval risk classification of the registered tool.
189    pub risk_class: ToolRiskClass,
190    /// Component that owns execution after authorization.
191    pub execution_owner: ToolExecutionOwner,
192    /// Resolution of the interaction that unblocked this call, if any.
193    pub interaction_resolution: Option<af_agent_session::InteractionResolution>,
194    /// Cancelled when the Run or step is abandoned.
195    pub cancellation: CancellationToken,
196    /// Latest time by which the work must finish.
197    pub deadline: Instant,
198}
199
200/// A chassis tool call derived from a successful tool result.
201#[derive(Debug, Clone, PartialEq)]
202pub struct ToolCompletionAction {
203    /// Registered tool to call through the normal authorization boundary.
204    pub tool: String,
205    /// Arguments validated against that tool's schema.
206    pub arguments: Value,
207}
208
209impl Default for ToolMeta {
210    fn default() -> Self {
211        Self {
212            surface: ToolSurface::Llm,
213            cost_units: 1,
214            timeout_secs: 15,
215            core: false,
216            concurrency: ToolConcurrency::Exclusive,
217            risk_class: ToolRiskClass::Unclassified,
218            execution_owner: ToolExecutionOwner::Unclassified,
219            requires_confirmation: false,
220        }
221    }
222}
223
224/// A callable tool. `parameters` is a JSON-Schema object describing the args.
225#[async_trait]
226pub trait Tool: Send + Sync {
227    /// Stable internal name.
228    fn name(&self) -> &str;
229    /// Implementation version pinned by Profile revisions.
230    fn implementation_version(&self) -> &str {
231        ""
232    }
233    /// Model-facing description.
234    fn description(&self) -> &str;
235    /// JSON Schema for the arguments.
236    fn parameters(&self) -> Value;
237    /// JSON Schema for the result.
238    fn output_schema(&self) -> Value;
239    /// Execution metadata.
240    fn meta(&self) -> ToolMeta {
241        ToolMeta::default()
242    }
243
244    /// Run the tool. On success return a JSON value; on failure return a short
245    /// error string (surfaced back to the model as `{"error": ...}` so it can
246    /// recover) — mirrors the Python "success → dict / failure → {error}" rule.
247    async fn call(&self, args: Value) -> Result<Value, String>;
248    /// Like [`call`](Self::call) with the execution context; the default ignores the context.
249    async fn call_with_context(
250        &self,
251        _context: &ToolExecutionContext,
252        args: Value,
253    ) -> Result<Value, String> {
254        self.call(args).await
255    }
256    /// Optionally request a chassis action after a successful validated result.
257    /// The runtime persists and authorizes the returned call before proceeding.
258    fn completion_action(&self, _result: &Value) -> Option<ToolCompletionAction> {
259        None
260    }
261}
262
263/// Registry of tools available to an agent.
264#[derive(Default, Clone)]
265pub struct ToolRegistry {
266    tools: HashMap<String, Arc<dyn Tool>>,
267    wire_names: HashMap<String, String>,
268    validators: HashMap<String, Arc<jsonschema::Validator>>,
269    output_validators: HashMap<String, Arc<jsonschema::Validator>>,
270}
271
272impl ToolRegistry {
273    /// An empty registry.
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Register a tool, compiling its schemas; duplicate or invalid tools are rejected.
279    pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<&mut Self, String> {
280        let name = tool.name().to_string();
281        if self.tools.contains_key(&name) {
282            return Err(format!("duplicate tool '{name}'"));
283        }
284        let wire_name = model_tool_name(&name);
285        if self.wire_names.contains_key(&wire_name)
286            || (wire_name != name && self.tools.contains_key(&wire_name))
287            || self.wire_names.contains_key(&name)
288        {
289            return Err(format!(
290                "tool name '{name}' collides on provider name '{wire_name}'"
291            ));
292        }
293        let validator = jsonschema::validator_for(&tool.parameters())
294            .map_err(|error| format!("invalid schema for tool '{name}': {error}"))?;
295        let output_validator = jsonschema::validator_for(&tool.output_schema())
296            .map_err(|error| format!("invalid output schema for tool '{name}': {error}"))?;
297        self.tools.insert(name.clone(), tool);
298        self.wire_names.insert(wire_name, name.clone());
299        self.validators.insert(name.clone(), Arc::new(validator));
300        self.output_validators
301            .insert(name, Arc::new(output_validator));
302        Ok(self)
303    }
304
305    /// Merge another registry; conflicting names are rejected.
306    pub fn extend(&mut self, other: &Self) -> Result<(), String> {
307        for tool in other.tools.values() {
308            self.register(Arc::clone(tool))?;
309        }
310        Ok(())
311    }
312
313    /// Whether no tools are registered.
314    pub fn is_empty(&self) -> bool {
315        self.tools.is_empty()
316    }
317
318    /// Number of registered tools.
319    pub fn len(&self) -> usize {
320        self.tools.len()
321    }
322
323    /// Whether `name` (internal or model form) is registered.
324    pub fn contains(&self, name: &str) -> bool {
325        self.tools.contains_key(name)
326    }
327
328    /// Look up a tool by internal or model name.
329    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
330        self.canonical_name(name)
331            .and_then(|name| self.tools.get(name))
332            .cloned()
333    }
334
335    /// Validate arguments against the tool's schema.
336    pub fn validate_arguments(&self, name: &str, arguments: &Value) -> Result<(), String> {
337        let name = self
338            .canonical_name(name)
339            .ok_or_else(|| self.unknown_tool_error(name))?;
340        self.validators
341            .get(name)
342            .ok_or_else(|| self.unknown_tool_error(name))?
343            .validate(arguments)
344            .map_err(|error| format!("invalid tool arguments: {error}"))
345    }
346
347    /// Validate a result against the tool's output schema.
348    pub fn validate_output(&self, name: &str, output: &Value) -> Result<(), String> {
349        let name = self
350            .canonical_name(name)
351            .ok_or_else(|| self.unknown_tool_error(name))?;
352        self.output_validators
353            .get(name)
354            .ok_or_else(|| self.unknown_tool_error(name))?
355            .validate(output)
356            .map_err(|error| format!("invalid tool output: {error}"))
357    }
358
359    /// Stable tool names for validation, diagnostics, and capability policy.
360    pub fn names(&self) -> Vec<&str> {
361        let mut names = self.tools.keys().map(String::as_str).collect::<Vec<_>>();
362        names.sort_unstable();
363        names
364    }
365
366    /// Tools whose metadata requires confirmation.
367    pub fn confirmation_required_names(&self) -> impl Iterator<Item = &str> {
368        self.tools
369            .values()
370            .filter(|tool| tool.meta().requires_confirmation)
371            .map(|tool| tool.name())
372    }
373
374    /// Return a registry containing only explicitly allowed tools.
375    pub fn filtered<'a>(&self, allowed: impl IntoIterator<Item = &'a str>) -> Self {
376        let mut filtered = Self::new();
377        for name in allowed {
378            if let Some(tool) = self.tools.get(name) {
379                // A subset of an already-validated registry cannot introduce a
380                // conflict, so a failure here is unreachable and safely ignored.
381                let _ = filtered.register(Arc::clone(tool));
382            }
383        }
384        filtered
385    }
386
387    /// LLM-facing tool specs for the `tools` field of a completion request.
388    pub fn specs(&self) -> Vec<LlmTool> {
389        let mut specs = self
390            .tools
391            .values()
392            .filter(|tool| tool.meta().surface == ToolSurface::Llm)
393            .map(|t| LlmTool::function(model_tool_name(t.name()), t.description(), t.parameters()))
394            .collect::<Vec<_>>();
395        specs.sort_by(|left, right| left.function.name.cmp(&right.function.name));
396        specs
397    }
398
399    /// Stable implementation and execution contract persisted by profile revisions.
400    pub fn runtime_manifest(&self) -> Result<Vec<Value>, String> {
401        self.names()
402            .into_iter()
403            .map(|name| {
404                let tool = self
405                    .tools
406                    .get(name)
407                    .ok_or_else(|| self.unknown_tool_error(name))?;
408                let version = tool.implementation_version().trim();
409                if version.is_empty() {
410                    return Err(format!(
411                        "tool '{name}' requires a stable implementation version"
412                    ));
413                }
414                let meta = tool.meta();
415                if meta.risk_class == ToolRiskClass::Unclassified
416                    || meta.execution_owner == ToolExecutionOwner::Unclassified
417                {
418                    return Err(format!(
419                        "tool '{name}' requires explicit risk_class and execution_owner"
420                    ));
421                }
422                Ok(serde_json::json!({
423                    "name": name,
424                    "implementation_version": version,
425                    "description": tool.description(),
426                    "parameters": tool.parameters(),
427                    "output_schema": tool.output_schema(),
428                    "surface": match meta.surface { ToolSurface::Llm => "llm", ToolSurface::Chassis => "chassis" },
429                    "timeout_secs": meta.timeout_secs,
430                    "concurrency": match meta.concurrency { ToolConcurrency::Concurrent => "concurrent", ToolConcurrency::Exclusive => "exclusive" },
431                    "cost_units": meta.cost_units,
432                    "core": meta.core,
433                    "risk_class": meta.risk_class.as_str(),
434                    "execution_owner": meta.execution_owner.as_str(),
435                    "requires_confirmation": meta.requires_confirmation,
436                }))
437            })
438            .collect()
439    }
440
441    /// Best-effort canonical name for case mistakes or an alphabetic junk
442    /// prefix glued to a registered tool name.
443    pub fn suggest_name(&self, name: &str) -> Option<&str> {
444        let name = name.trim();
445        if name.is_empty() || self.tools.contains_key(name) {
446            return None;
447        }
448        if let Some((canonical, _)) = self
449            .tools
450            .iter()
451            .find(|(canonical, _)| canonical.eq_ignore_ascii_case(name))
452        {
453            return Some(canonical);
454        }
455
456        let lower = name.to_ascii_lowercase();
457        self.tools
458            .keys()
459            .filter(|canonical| {
460                let canonical = canonical.to_ascii_lowercase();
461                let prefix_len = lower.len().checked_sub(canonical.len());
462                let plural_prefix_len = lower
463                    .strip_suffix('s')
464                    .and_then(|singular| singular.len().checked_sub(canonical.len()))
465                    .filter(|_| lower[..lower.len() - 1].ends_with(&canonical));
466                prefix_len
467                    .filter(|_| lower.ends_with(&canonical))
468                    .or(plural_prefix_len)
469                    .is_some_and(|len| {
470                        len > 0
471                            && name
472                                .as_bytes()
473                                .get(..len)
474                                .is_some_and(|prefix| prefix.iter().all(u8::is_ascii_alphabetic))
475                    })
476            })
477            .max_by_key(|canonical| canonical.len())
478            .map(String::as_str)
479    }
480
481    fn unknown_tool_error(&self, name: &str) -> String {
482        let available = if self.tools.is_empty() {
483            "(none registered)".to_string()
484        } else {
485            self.names().join(", ")
486        };
487        match self.suggest_name(name) {
488            Some(suggestion) => format!(
489                "unknown tool '{name}'. Did you mean '{suggestion}'? Call tools by their exact registered name. Available: {available}"
490            ),
491            None => format!(
492                "unknown tool '{name}'. Call one of the registered tools by exact name. Available: {available}"
493            ),
494        }
495    }
496
497    /// Validate, execute and validate the result of one tool call.
498    pub async fn execute_with_context(
499        &self,
500        name: &str,
501        context: &ToolExecutionContext,
502        args: Value,
503    ) -> Result<Value, String> {
504        if let Some(canonical) = self.canonical_name(name) {
505            self.validate_arguments(canonical, &args)?;
506            let tool = self
507                .tools
508                .get(canonical)
509                .ok_or_else(|| self.unknown_tool_error(canonical))?;
510            let result = tool.call_with_context(context, args).await;
511            let value = result?;
512            self.validate_output(canonical, &value)?;
513            return Ok(value);
514        }
515        Err(self.unknown_tool_error(name))
516    }
517
518    /// Internal name for an internal or model-facing name.
519    pub fn canonical_name<'a>(&'a self, name: &'a str) -> Option<&'a str> {
520        if self.tools.contains_key(name) {
521            return Some(name);
522        }
523        self.wire_names
524            .get(name)
525            .map(String::as_str)
526            .or_else(|| self.suggest_name(name))
527    }
528}
529
530/// Validate `value` against `schema`.
531pub fn validate_json_schema(schema: &Value, value: &Value) -> Result<(), String> {
532    validate_json_schema_value(schema, value, "tool arguments")
533}
534
535/// Check that `schema` is a valid JSON Schema.
536pub fn validate_json_schema_definition(schema: &Value) -> Result<(), String> {
537    jsonschema::validator_for(schema)
538        .map(|_| ())
539        .map_err(|error| format!("invalid JSON schema: {error}"))
540}
541
542/// Validate `value` against `schema`, labelling errors with `subject`.
543pub fn validate_json_schema_value(
544    schema: &Value,
545    value: &Value,
546    subject: &str,
547) -> Result<(), String> {
548    let validator = jsonschema::validator_for(schema)
549        .map_err(|error| format!("invalid JSON schema: {error}"))?;
550    validator
551        .validate(value)
552        .map_err(|error| format!("invalid {subject}: {error}"))
553}
554
555/// Model-facing tool name (dots become underscores).
556pub fn model_tool_name(internal: &str) -> String {
557    if !internal.is_empty()
558        && internal.len() <= 64
559        && internal
560            .bytes()
561            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
562    {
563        return internal.to_string();
564    }
565    let mut prefix = internal
566        .bytes()
567        .map(|byte| {
568            if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
569                byte as char
570            } else {
571                '_'
572            }
573        })
574        .take(47)
575        .collect::<String>();
576    if prefix.is_empty() {
577        prefix.push_str("tool");
578    }
579    let digest = format!("{:x}", Sha256::digest(internal.as_bytes()));
580    format!("{prefix}_{}", &digest[..16])
581}
582
583/// Helpers for implementing [`Tool`].
584pub mod support {
585    use serde::de::DeserializeOwned;
586    use serde_json::Value;
587
588    /// Provides the argument schema for a typed tool.
589    pub trait RawToolSchema {
590        /// JSON Schema for the arguments.
591        fn parameters() -> Value;
592    }
593
594    /// Deserialize a required argument.
595    pub fn extract_required<T: DeserializeOwned>(args: &Value, name: &str) -> Result<T, String> {
596        let value = args
597            .get(name)
598            .cloned()
599            .ok_or_else(|| format!("missing required argument '{name}'"))?;
600        serde_json::from_value(value).map_err(|error| format!("invalid argument '{name}': {error}"))
601    }
602
603    /// Deserialize an optional argument.
604    pub fn extract_optional<T: DeserializeOwned>(
605        args: &Value,
606        name: &str,
607    ) -> Result<Option<T>, String> {
608        match args.get(name) {
609            None | Some(Value::Null) => Ok(None),
610            Some(value) => serde_json::from_value(value.clone())
611                .map(Some)
612                .map_err(|error| format!("invalid argument '{name}': {error}")),
613        }
614    }
615}
616
617impl fmt::Debug for ToolRegistry {
618    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
619        f.debug_struct("ToolRegistry")
620            .field("tools", &self.names())
621            .finish()
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    fn execution() -> ToolExecutionContext {
630        ToolExecutionContext {
631            request: crate::RequestContext {
632                tenant_id: "tenant".parse().unwrap(),
633                subject_id: "subject".parse().unwrap(),
634                roles: Default::default(),
635                locale: "en".into(),
636                request_id: "request".parse().unwrap(),
637                entitlements: Default::default(),
638            },
639            session_id: "session".parse().unwrap(),
640            run_id: "run".parse().unwrap(),
641            step: 1,
642            call_id: "call".parse().unwrap(),
643            source_event_seq: 1,
644            risk_class: ToolRiskClass::Read,
645            execution_owner: ToolExecutionOwner::Runtime,
646            interaction_resolution: None,
647            cancellation: CancellationToken::default(),
648            deadline: Instant::now() + std::time::Duration::from_secs(1),
649        }
650    }
651
652    struct TestTool(&'static str);
653    #[async_trait]
654    impl Tool for TestTool {
655        fn name(&self) -> &str {
656            self.0
657        }
658        fn description(&self) -> &str {
659            "test"
660        }
661        fn parameters(&self) -> Value {
662            serde_json::json!({
663                "type":"object",
664                "required":["items","mode"],
665                "additionalProperties":false,
666                "properties":{
667                    "items":{"type":"array","items":{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}},
668                    "mode":{"enum":["safe","fast"]},
669                    "version":{"const":1},
670                    "choice":{"oneOf":[{"type":"string"},{"type":"number"}]}
671                }
672            })
673        }
674        fn output_schema(&self) -> Value {
675            serde_json::json!({"type":"object"})
676        }
677        fn implementation_version(&self) -> &str {
678            "1"
679        }
680        async fn call(&self, args: Value) -> Result<Value, String> {
681            Ok(args)
682        }
683    }
684
685    #[test]
686    fn runtime_manifest_rejects_missing_execution_metadata() {
687        let mut registry = ToolRegistry::new();
688        registry
689            .register(Arc::new(TestTool("unclassified")))
690            .unwrap();
691        assert!(registry
692            .runtime_manifest()
693            .unwrap_err()
694            .contains("explicit risk_class and execution_owner"));
695    }
696
697    #[tokio::test]
698    async fn registry_rejects_duplicates_and_validates_full_schema() {
699        let mut registry = ToolRegistry::new();
700        registry
701            .register(Arc::new(TestTool("nested.tool")))
702            .unwrap();
703        assert!(registry
704            .register(Arc::new(TestTool("nested.tool")))
705            .is_err());
706        let valid = serde_json::json!({"items":[{"id":1}],"mode":"safe","version":1,"choice":"x"});
707        assert_eq!(
708            registry
709                .execute_with_context("nested.tool", &execution(), valid.clone())
710                .await
711                .unwrap(),
712            valid
713        );
714        for invalid in [
715            serde_json::json!({"items":[{}],"mode":"safe"}),
716            serde_json::json!({"items":[{"id":1}],"mode":"unsafe"}),
717            serde_json::json!({"items":[{"id":1}],"mode":"safe","extra":true}),
718            serde_json::json!({"items":[{"id":1}],"mode":"safe","version":2}),
719            serde_json::json!({"items":[{"id":1}],"mode":"safe","choice":true}),
720        ] {
721            assert!(registry
722                .execute_with_context("nested.tool", &execution(), invalid)
723                .await
724                .is_err());
725        }
726    }
727
728    #[test]
729    fn model_names_are_provider_safe_and_reversible() {
730        let mut registry = ToolRegistry::new();
731        registry.register(Arc::new(TestTool("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"))).unwrap();
732        let spec = registry.specs().pop().unwrap().function.name;
733        assert!(spec.len() <= 64);
734        assert!(spec
735            .bytes()
736            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
737        assert_eq!(registry.canonical_name(&spec), Some("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"));
738
739        let mut collision = ToolRegistry::new();
740        collision.register(Arc::new(TestTool("a.b"))).unwrap();
741        assert_eq!(model_tool_name("a.b"), "a_b_2e7336dc8eba87ef");
742        assert!(collision
743            .register(Arc::new(TestTool("a_b_2e7336dc8eba87ef")))
744            .is_err());
745    }
746
747    #[tokio::test]
748    async fn provider_junk_prefixes_resolve_once_at_the_registry_boundary() {
749        let mut registry = ToolRegistry::new();
750        registry
751            .register(Arc::new(TestTool("analyze_wallet")))
752            .unwrap();
753        let args = serde_json::json!({"items":[{"id":1}],"mode":"safe"});
754
755        assert_eq!(
756            registry
757                .execute_with_context("Notebookanalyze_wallet", &execution(), args.clone())
758                .await
759                .unwrap(),
760            args
761        );
762        assert!(registry
763            .execute_with_context("Listanalyze_wallets", &execution(), args.clone())
764            .await
765            .is_ok());
766        assert!(registry
767            .execute_with_context("namespace.analyze_wallet", &execution(), args)
768            .await
769            .is_err());
770    }
771
772    #[test]
773    fn invalid_schema_is_rejected_at_registration() {
774        struct Invalid;
775        #[async_trait]
776        impl Tool for Invalid {
777            fn name(&self) -> &str {
778                "invalid"
779            }
780            fn description(&self) -> &str {
781                "invalid"
782            }
783            fn parameters(&self) -> Value {
784                serde_json::json!({"type":"not-a-type"})
785            }
786            fn output_schema(&self) -> Value {
787                serde_json::json!({"type":"object"})
788            }
789            async fn call(&self, _: Value) -> Result<Value, String> {
790                Ok(Value::Null)
791            }
792        }
793        assert!(ToolRegistry::new().register(Arc::new(Invalid)).is_err());
794    }
795
796    #[tokio::test]
797    async fn successful_output_is_validated_before_materialization() {
798        struct InvalidOutput;
799        #[async_trait]
800        impl Tool for InvalidOutput {
801            fn name(&self) -> &str {
802                "invalid-output"
803            }
804            fn description(&self) -> &str {
805                "invalid output"
806            }
807            fn parameters(&self) -> Value {
808                serde_json::json!({"type":"object"})
809            }
810            fn output_schema(&self) -> Value {
811                serde_json::json!({"type":"object"})
812            }
813            async fn call(&self, _: Value) -> Result<Value, String> {
814                Ok(Value::String("bad".into()))
815            }
816        }
817        let mut registry = ToolRegistry::new();
818        registry.register(Arc::new(InvalidOutput)).unwrap();
819        assert!(registry
820            .execute_with_context("invalid-output", &execution(), serde_json::json!({}))
821            .await
822            .unwrap_err()
823            .contains("invalid tool output"));
824    }
825}