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/// Actual usage reported by one completed tool execution.
119#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
120pub struct ToolUsage {
121    /// Model input tokens consumed inside the tool.
122    pub prompt_tokens: u64,
123    /// Model output tokens consumed inside the tool.
124    pub completion_tokens: u64,
125    /// Provider-neutral non-token cost units.
126    pub cost_units: u64,
127}
128
129/// Hierarchical cancellation flag. Cancelling a parent cancels every child;
130/// [`cancelled`](Self::cancelled) resolves without polling.
131#[derive(Debug, Clone, Default)]
132pub struct CancellationToken(Arc<CancellationState>);
133
134#[derive(Debug, Default)]
135struct CancellationState {
136    cancelled: AtomicBool,
137    notify: tokio::sync::Notify,
138    parent: Option<CancellationToken>,
139}
140
141impl CancellationToken {
142    /// Cancel this token and every child.
143    pub fn cancel(&self) {
144        self.0.cancelled.store(true, Ordering::Release);
145        self.0.notify.notify_waiters();
146    }
147    /// Whether this token or an ancestor was cancelled.
148    pub fn is_cancelled(&self) -> bool {
149        self.0.cancelled.load(Ordering::Acquire)
150            || self
151                .0
152                .parent
153                .as_ref()
154                .is_some_and(CancellationToken::is_cancelled)
155    }
156    /// A child token cancelled with its parent.
157    pub fn child(&self) -> Self {
158        Self(Arc::new(CancellationState {
159            cancelled: AtomicBool::new(false),
160            notify: tokio::sync::Notify::new(),
161            parent: Some(self.clone()),
162        }))
163    }
164    /// Resolves once this token or any ancestor is cancelled.
165    pub fn cancelled(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
166        Box::pin(async move {
167            let notified = self.0.notify.notified();
168            tokio::pin!(notified);
169            notified.as_mut().enable();
170            if self.is_cancelled() {
171                return;
172            }
173            match &self.0.parent {
174                Some(parent) => tokio::select! {
175                    _ = notified => {}
176                    _ = parent.cancelled() => {}
177                },
178                None => notified.await,
179            }
180        })
181    }
182}
183
184/// Identity, position and controls for one tool execution.
185#[derive(Debug, Clone)]
186pub struct ToolExecutionContext {
187    /// The originating request.
188    pub request: af_context::RequestContext,
189    /// Session this record belongs to.
190    pub session_id: SessionId,
191    /// Run this record belongs to.
192    pub run_id: RunId,
193    /// 1-based step number inside the Turn.
194    pub step: u32,
195    /// Tool call this record refers to.
196    pub call_id: ToolCallId,
197    /// Sequence of the Session event this was derived from.
198    pub source_event_seq: u64,
199    /// Explicit approval risk classification of the registered tool.
200    pub risk_class: ToolRiskClass,
201    /// Component that owns execution after authorization.
202    pub execution_owner: ToolExecutionOwner,
203    /// Resolution of the interaction that unblocked this call, if any.
204    pub interaction_resolution: Option<af_agent_session::InteractionResolution>,
205    /// Cancelled when the Run or step is abandoned.
206    pub cancellation: CancellationToken,
207    /// Latest time by which the work must finish.
208    pub deadline: Instant,
209}
210
211/// A chassis tool call derived from a successful tool result.
212#[derive(Debug, Clone, PartialEq)]
213pub struct ToolCompletionAction {
214    /// Registered tool to call through the normal authorization boundary.
215    pub tool: String,
216    /// Arguments validated against that tool's schema.
217    pub arguments: Value,
218}
219
220impl Default for ToolMeta {
221    fn default() -> Self {
222        Self {
223            surface: ToolSurface::Llm,
224            cost_units: 1,
225            timeout_secs: 15,
226            core: false,
227            concurrency: ToolConcurrency::Exclusive,
228            risk_class: ToolRiskClass::Unclassified,
229            execution_owner: ToolExecutionOwner::Unclassified,
230            requires_confirmation: false,
231        }
232    }
233}
234
235/// A callable tool. `parameters` is a JSON-Schema object describing the args.
236#[async_trait]
237pub trait Tool: Send + Sync {
238    /// Stable internal name.
239    fn name(&self) -> &str;
240    /// Implementation version pinned by Profile revisions.
241    fn implementation_version(&self) -> &str {
242        ""
243    }
244    /// Model-facing description.
245    fn description(&self) -> &str;
246    /// JSON Schema for the arguments.
247    fn parameters(&self) -> Value;
248    /// JSON Schema for the result.
249    fn output_schema(&self) -> Value;
250    /// Execution metadata.
251    fn meta(&self) -> ToolMeta {
252        ToolMeta::default()
253    }
254    /// Whether an authenticated, private response artifact may be created in
255    /// `confirm_changes` mode without an extra prompt. Never use for external
256    /// side effects, funds, or writes not already explicit in the user request.
257    fn auto_approve_response_artifact(&self) -> bool {
258        false
259    }
260    /// Return the actual usage for a successful result. Tools with dynamic
261    /// provider usage can override this; static tools inherit `meta.cost_units`.
262    fn usage(&self, _result: &Value) -> Option<ToolUsage> {
263        None
264    }
265
266    /// Run the tool. On success return a JSON value; on failure return a short
267    /// error string (surfaced back to the model as `{"error": ...}` so it can
268    /// recover) — mirrors the Python "success → dict / failure → {error}" rule.
269    async fn call(&self, args: Value) -> Result<Value, String>;
270    /// Like [`call`](Self::call) with the execution context; the default ignores the context.
271    async fn call_with_context(
272        &self,
273        _context: &ToolExecutionContext,
274        args: Value,
275    ) -> Result<Value, String> {
276        self.call(args).await
277    }
278    /// Optionally request a chassis action after a successful validated result.
279    /// The runtime persists and authorizes the returned call before proceeding.
280    fn completion_action(&self, _result: &Value) -> Option<ToolCompletionAction> {
281        None
282    }
283}
284
285/// Registry of tools available to an agent.
286#[derive(Default, Clone)]
287pub struct ToolRegistry {
288    tools: HashMap<String, Arc<dyn Tool>>,
289    wire_names: HashMap<String, String>,
290    validators: HashMap<String, Arc<jsonschema::Validator>>,
291    output_validators: HashMap<String, Arc<jsonschema::Validator>>,
292}
293
294impl ToolRegistry {
295    /// An empty registry.
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// Register a tool, compiling its schemas; duplicate or invalid tools are rejected.
301    pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<&mut Self, String> {
302        let name = tool.name().to_string();
303        if self.tools.contains_key(&name) {
304            return Err(format!("duplicate tool '{name}'"));
305        }
306        let wire_name = model_tool_name(&name);
307        if self.wire_names.contains_key(&wire_name)
308            || (wire_name != name && self.tools.contains_key(&wire_name))
309            || self.wire_names.contains_key(&name)
310        {
311            return Err(format!(
312                "tool name '{name}' collides on provider name '{wire_name}'"
313            ));
314        }
315        let validator = jsonschema::validator_for(&tool.parameters())
316            .map_err(|error| format!("invalid schema for tool '{name}': {error}"))?;
317        let output_validator = jsonschema::validator_for(&tool.output_schema())
318            .map_err(|error| format!("invalid output schema for tool '{name}': {error}"))?;
319        self.tools.insert(name.clone(), tool);
320        self.wire_names.insert(wire_name, name.clone());
321        self.validators.insert(name.clone(), Arc::new(validator));
322        self.output_validators
323            .insert(name, Arc::new(output_validator));
324        Ok(self)
325    }
326
327    /// Merge another registry; conflicting names are rejected.
328    pub fn extend(&mut self, other: &Self) -> Result<(), String> {
329        for tool in other.tools.values() {
330            self.register(Arc::clone(tool))?;
331        }
332        Ok(())
333    }
334
335    /// Whether no tools are registered.
336    pub fn is_empty(&self) -> bool {
337        self.tools.is_empty()
338    }
339
340    /// Number of registered tools.
341    pub fn len(&self) -> usize {
342        self.tools.len()
343    }
344
345    /// Whether `name` (internal or model form) is registered.
346    pub fn contains(&self, name: &str) -> bool {
347        self.tools.contains_key(name)
348    }
349
350    /// Look up a tool by internal or model name.
351    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
352        self.canonical_name(name)
353            .and_then(|name| self.tools.get(name))
354            .cloned()
355    }
356
357    /// Validate arguments against the tool's schema.
358    pub fn validate_arguments(&self, name: &str, arguments: &Value) -> Result<(), String> {
359        let name = self
360            .canonical_name(name)
361            .ok_or_else(|| self.unknown_tool_error(name))?;
362        self.validators
363            .get(name)
364            .ok_or_else(|| self.unknown_tool_error(name))?
365            .validate(arguments)
366            .map_err(|error| format!("invalid tool arguments: {error}"))
367    }
368
369    /// Resolve a provider-returned name, repairing it only when the arguments
370    /// uniquely match one registered tool schema. Authorization still uses the
371    /// resolved tool's own policy.
372    pub fn resolve_call_name<'a>(
373        &'a self,
374        name: &str,
375        arguments: &Value,
376    ) -> Result<&'a str, String> {
377        let original_error = self.validate_arguments(name, arguments).err();
378        if original_error.is_none() {
379            let canonical = self
380                .canonical_name(name)
381                .ok_or_else(|| self.unknown_tool_error(name))?;
382            return self
383                .tools
384                .get_key_value(canonical)
385                .map(|(registered, _)| registered.as_str())
386                .ok_or_else(|| self.unknown_tool_error(name));
387        }
388        let argument_keys = arguments.as_object().map(|object| object.keys());
389        let mut matches = self
390            .tools
391            .iter()
392            .filter(|(candidate, tool)| {
393                self.validate_arguments(candidate, arguments).is_ok()
394                    && argument_keys.clone().is_some_and(|keys| {
395                        let schema = tool.parameters();
396                        let properties = schema.get("properties").and_then(Value::as_object);
397                        keys.into_iter().all(|key| {
398                            properties.is_some_and(|properties| properties.contains_key(key))
399                        })
400                    })
401            })
402            .map(|(candidate, _)| candidate);
403        let candidate = matches.next();
404        match (candidate, matches.next()) {
405            (Some(candidate), None) => Ok(candidate),
406            _ => Err(original_error.expect("invalid original call has an error")),
407        }
408    }
409
410    /// Validate a result against the tool's output schema.
411    pub fn validate_output(&self, name: &str, output: &Value) -> Result<(), String> {
412        let name = self
413            .canonical_name(name)
414            .ok_or_else(|| self.unknown_tool_error(name))?;
415        self.output_validators
416            .get(name)
417            .ok_or_else(|| self.unknown_tool_error(name))?
418            .validate(output)
419            .map_err(|error| format!("invalid tool output: {error}"))
420    }
421
422    /// Resolve a completed tool's actual usage.
423    pub fn usage(&self, name: &str, output: &Value) -> Option<ToolUsage> {
424        self.get(name).and_then(|tool| tool.usage(output))
425    }
426
427    /// Stable tool names for validation, diagnostics, and capability policy.
428    pub fn names(&self) -> Vec<&str> {
429        let mut names = self.tools.keys().map(String::as_str).collect::<Vec<_>>();
430        names.sort_unstable();
431        names
432    }
433
434    /// Tools whose metadata requires confirmation.
435    pub fn confirmation_required_names(&self) -> impl Iterator<Item = &str> {
436        self.tools
437            .values()
438            .filter(|tool| tool.meta().requires_confirmation)
439            .map(|tool| tool.name())
440    }
441
442    /// Return a registry containing only explicitly allowed tools.
443    pub fn filtered<'a>(&self, allowed: impl IntoIterator<Item = &'a str>) -> Self {
444        let mut filtered = Self::new();
445        for name in allowed {
446            if let Some(tool) = self.tools.get(name) {
447                // A subset of an already-validated registry cannot introduce a
448                // conflict, so a failure here is unreachable and safely ignored.
449                let _ = filtered.register(Arc::clone(tool));
450            }
451        }
452        filtered
453    }
454
455    /// LLM-facing tool specs for the `tools` field of a completion request.
456    pub fn specs(&self) -> Vec<LlmTool> {
457        let mut specs = self
458            .tools
459            .values()
460            .filter(|tool| tool.meta().surface == ToolSurface::Llm)
461            .map(|t| LlmTool::function(model_tool_name(t.name()), t.description(), t.parameters()))
462            .collect::<Vec<_>>();
463        specs.sort_by(|left, right| left.function.name.cmp(&right.function.name));
464        specs
465    }
466
467    /// Stable implementation and execution contract persisted by profile revisions.
468    pub fn runtime_manifest(&self) -> Result<Vec<Value>, String> {
469        self.names()
470            .into_iter()
471            .map(|name| {
472                let tool = self
473                    .tools
474                    .get(name)
475                    .ok_or_else(|| self.unknown_tool_error(name))?;
476                let version = tool.implementation_version().trim();
477                if version.is_empty() {
478                    return Err(format!(
479                        "tool '{name}' requires a stable implementation version"
480                    ));
481                }
482                let meta = tool.meta();
483                if meta.risk_class == ToolRiskClass::Unclassified
484                    || meta.execution_owner == ToolExecutionOwner::Unclassified
485                {
486                    return Err(format!(
487                        "tool '{name}' requires explicit risk_class and execution_owner"
488                    ));
489                }
490                Ok(serde_json::json!({
491                    "name": name,
492                    "implementation_version": version,
493                    "description": tool.description(),
494                    "parameters": tool.parameters(),
495                    "output_schema": tool.output_schema(),
496                    "surface": match meta.surface { ToolSurface::Llm => "llm", ToolSurface::Chassis => "chassis" },
497                    "timeout_secs": meta.timeout_secs,
498                    "concurrency": match meta.concurrency { ToolConcurrency::Concurrent => "concurrent", ToolConcurrency::Exclusive => "exclusive" },
499                    "cost_units": meta.cost_units,
500                    "core": meta.core,
501                    "risk_class": meta.risk_class.as_str(),
502                    "execution_owner": meta.execution_owner.as_str(),
503                    "requires_confirmation": meta.requires_confirmation,
504                }))
505            })
506            .collect()
507    }
508
509    /// Best-effort canonical name for case mistakes or an alphabetic junk
510    /// prefix glued to a registered tool name.
511    pub fn suggest_name(&self, name: &str) -> Option<&str> {
512        let name = name.trim();
513        if name.is_empty() || self.tools.contains_key(name) {
514            return None;
515        }
516        if let Some((canonical, _)) = self
517            .tools
518            .iter()
519            .find(|(canonical, _)| canonical.eq_ignore_ascii_case(name))
520        {
521            return Some(canonical);
522        }
523
524        let lower = name.to_ascii_lowercase();
525        self.tools
526            .keys()
527            .filter(|canonical| {
528                let canonical = canonical.to_ascii_lowercase();
529                let prefix_len = lower.len().checked_sub(canonical.len());
530                let plural_prefix_len = lower
531                    .strip_suffix('s')
532                    .and_then(|singular| singular.len().checked_sub(canonical.len()))
533                    .filter(|_| lower[..lower.len() - 1].ends_with(&canonical));
534                prefix_len
535                    .filter(|_| lower.ends_with(&canonical))
536                    .or(plural_prefix_len)
537                    .is_some_and(|len| {
538                        len > 0
539                            && name
540                                .as_bytes()
541                                .get(..len)
542                                .is_some_and(|prefix| prefix.iter().all(u8::is_ascii_alphabetic))
543                    })
544            })
545            .max_by_key(|canonical| canonical.len())
546            .map(String::as_str)
547    }
548
549    fn unknown_tool_error(&self, name: &str) -> String {
550        let available = if self.tools.is_empty() {
551            "(none registered)".to_string()
552        } else {
553            self.names().join(", ")
554        };
555        match self.suggest_name(name) {
556            Some(suggestion) => format!(
557                "unknown tool '{name}'. Did you mean '{suggestion}'? Call tools by their exact registered name. Available: {available}"
558            ),
559            None => format!(
560                "unknown tool '{name}'. Call one of the registered tools by exact name. Available: {available}"
561            ),
562        }
563    }
564
565    /// Validate, execute and validate the result of one tool call.
566    pub async fn execute_with_context(
567        &self,
568        name: &str,
569        context: &ToolExecutionContext,
570        args: Value,
571    ) -> Result<Value, String> {
572        if let Some(canonical) = self.canonical_name(name) {
573            self.validate_arguments(canonical, &args)?;
574            let tool = self
575                .tools
576                .get(canonical)
577                .ok_or_else(|| self.unknown_tool_error(canonical))?;
578            let result = tool.call_with_context(context, args).await;
579            let value = result?;
580            self.validate_output(canonical, &value)?;
581            return Ok(value);
582        }
583        Err(self.unknown_tool_error(name))
584    }
585
586    /// Internal name for an internal or model-facing name.
587    pub fn canonical_name<'a>(&'a self, name: &'a str) -> Option<&'a str> {
588        if self.tools.contains_key(name) {
589            return Some(name);
590        }
591        self.wire_names
592            .get(name)
593            .map(String::as_str)
594            .or_else(|| self.suggest_name(name))
595    }
596}
597
598/// Validate `value` against `schema`.
599pub fn validate_json_schema(schema: &Value, value: &Value) -> Result<(), String> {
600    validate_json_schema_value(schema, value, "tool arguments")
601}
602
603/// Check that `schema` is a valid JSON Schema.
604pub fn validate_json_schema_definition(schema: &Value) -> Result<(), String> {
605    jsonschema::validator_for(schema)
606        .map(|_| ())
607        .map_err(|error| format!("invalid JSON schema: {error}"))
608}
609
610/// Validate `value` against `schema`, labelling errors with `subject`.
611pub fn validate_json_schema_value(
612    schema: &Value,
613    value: &Value,
614    subject: &str,
615) -> Result<(), String> {
616    let validator = jsonschema::validator_for(schema)
617        .map_err(|error| format!("invalid JSON schema: {error}"))?;
618    validator
619        .validate(value)
620        .map_err(|error| format!("invalid {subject}: {error}"))
621}
622
623/// Model-facing tool name (dots become underscores).
624pub fn model_tool_name(internal: &str) -> String {
625    if !internal.is_empty()
626        && internal.len() <= 64
627        && internal
628            .bytes()
629            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
630    {
631        return internal.to_string();
632    }
633    let mut prefix = internal
634        .bytes()
635        .map(|byte| {
636            if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
637                byte as char
638            } else {
639                '_'
640            }
641        })
642        .take(47)
643        .collect::<String>();
644    if prefix.is_empty() {
645        prefix.push_str("tool");
646    }
647    let digest = format!("{:x}", Sha256::digest(internal.as_bytes()));
648    format!("{prefix}_{}", &digest[..16])
649}
650
651/// Helpers for implementing [`Tool`].
652pub mod support {
653    use serde::de::DeserializeOwned;
654    use serde_json::Value;
655
656    /// Provides the argument schema for a typed tool.
657    pub trait RawToolSchema {
658        /// JSON Schema for the arguments.
659        fn parameters() -> Value;
660    }
661
662    /// Deserialize a required argument.
663    pub fn extract_required<T: DeserializeOwned>(args: &Value, name: &str) -> Result<T, String> {
664        let value = args
665            .get(name)
666            .cloned()
667            .ok_or_else(|| format!("missing required argument '{name}'"))?;
668        serde_json::from_value(value).map_err(|error| format!("invalid argument '{name}': {error}"))
669    }
670
671    /// Deserialize an optional argument.
672    pub fn extract_optional<T: DeserializeOwned>(
673        args: &Value,
674        name: &str,
675    ) -> Result<Option<T>, String> {
676        match args.get(name) {
677            None | Some(Value::Null) => Ok(None),
678            Some(value) => serde_json::from_value(value.clone())
679                .map(Some)
680                .map_err(|error| format!("invalid argument '{name}': {error}")),
681        }
682    }
683}
684
685impl fmt::Debug for ToolRegistry {
686    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
687        f.debug_struct("ToolRegistry")
688            .field("tools", &self.names())
689            .finish()
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    fn execution() -> ToolExecutionContext {
698        ToolExecutionContext {
699            request: crate::RequestContext {
700                tenant_id: "tenant".parse().unwrap(),
701                subject_id: "subject".parse().unwrap(),
702                roles: Default::default(),
703                locale: "en".into(),
704                request_id: "request".parse().unwrap(),
705                entitlements: Default::default(),
706            },
707            session_id: "session".parse().unwrap(),
708            run_id: "run".parse().unwrap(),
709            step: 1,
710            call_id: "call".parse().unwrap(),
711            source_event_seq: 1,
712            risk_class: ToolRiskClass::Read,
713            execution_owner: ToolExecutionOwner::Runtime,
714            interaction_resolution: None,
715            cancellation: CancellationToken::default(),
716            deadline: Instant::now() + std::time::Duration::from_secs(1),
717        }
718    }
719
720    struct TestTool(&'static str);
721    #[async_trait]
722    impl Tool for TestTool {
723        fn name(&self) -> &str {
724            self.0
725        }
726        fn description(&self) -> &str {
727            "test"
728        }
729        fn parameters(&self) -> Value {
730            serde_json::json!({
731                "type":"object",
732                "required":["items","mode"],
733                "additionalProperties":false,
734                "properties":{
735                    "items":{"type":"array","items":{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}},
736                    "mode":{"enum":["safe","fast"]},
737                    "version":{"const":1},
738                    "choice":{"oneOf":[{"type":"string"},{"type":"number"}]}
739                }
740            })
741        }
742        fn output_schema(&self) -> Value {
743            serde_json::json!({"type":"object"})
744        }
745        fn implementation_version(&self) -> &str {
746            "1"
747        }
748        async fn call(&self, args: Value) -> Result<Value, String> {
749            Ok(args)
750        }
751    }
752
753    #[test]
754    fn runtime_manifest_rejects_missing_execution_metadata() {
755        let mut registry = ToolRegistry::new();
756        registry
757            .register(Arc::new(TestTool("unclassified")))
758            .unwrap();
759        assert!(registry
760            .runtime_manifest()
761            .unwrap_err()
762            .contains("explicit risk_class and execution_owner"));
763    }
764
765    #[tokio::test]
766    async fn registry_rejects_duplicates_and_validates_full_schema() {
767        let mut registry = ToolRegistry::new();
768        registry
769            .register(Arc::new(TestTool("nested.tool")))
770            .unwrap();
771        assert!(registry
772            .register(Arc::new(TestTool("nested.tool")))
773            .is_err());
774        let valid = serde_json::json!({"items":[{"id":1}],"mode":"safe","version":1,"choice":"x"});
775        assert_eq!(
776            registry
777                .execute_with_context("nested.tool", &execution(), valid.clone())
778                .await
779                .unwrap(),
780            valid
781        );
782        for invalid in [
783            serde_json::json!({"items":[{}],"mode":"safe"}),
784            serde_json::json!({"items":[{"id":1}],"mode":"unsafe"}),
785            serde_json::json!({"items":[{"id":1}],"mode":"safe","extra":true}),
786            serde_json::json!({"items":[{"id":1}],"mode":"safe","version":2}),
787            serde_json::json!({"items":[{"id":1}],"mode":"safe","choice":true}),
788        ] {
789            assert!(registry
790                .execute_with_context("nested.tool", &execution(), invalid)
791                .await
792                .is_err());
793        }
794    }
795
796    #[test]
797    fn provider_name_mismatch_repairs_only_a_unique_schema_match() {
798        struct CoinTool(&'static str);
799        struct PermissiveTool;
800        #[async_trait]
801        impl Tool for CoinTool {
802            fn name(&self) -> &str {
803                self.0
804            }
805            fn description(&self) -> &str {
806                "coin"
807            }
808            fn parameters(&self) -> Value {
809                serde_json::json!({"type":"object","required":["coin"],"additionalProperties":false,"properties":{"coin":{"type":"string"}}})
810            }
811            fn output_schema(&self) -> Value {
812                serde_json::json!({"type":"object"})
813            }
814            fn implementation_version(&self) -> &str {
815                "1"
816            }
817            async fn call(&self, args: Value) -> Result<Value, String> {
818                Ok(args)
819            }
820        }
821        #[async_trait]
822        impl Tool for PermissiveTool {
823            fn name(&self) -> &str {
824                "permissive"
825            }
826            fn description(&self) -> &str {
827                "accepts undeclared extra arguments"
828            }
829            fn parameters(&self) -> Value {
830                serde_json::json!({"type":"object","properties":{"query":{"type":"string"}}})
831            }
832            fn output_schema(&self) -> Value {
833                serde_json::json!({"type":"object"})
834            }
835            async fn call(&self, args: Value) -> Result<Value, String> {
836                Ok(args)
837            }
838        }
839        let mut registry = ToolRegistry::new();
840        registry.register(Arc::new(TestTool("wrong"))).unwrap();
841        registry.register(Arc::new(PermissiveTool)).unwrap();
842        registry.register(Arc::new(CoinTool("candles"))).unwrap();
843        let args = serde_json::json!({"coin":"BTC"});
844        assert_eq!(
845            registry.resolve_call_name("wrong", &args).unwrap(),
846            "candles"
847        );
848        registry.register(Arc::new(CoinTool("snapshot"))).unwrap();
849        assert!(registry.resolve_call_name("wrong", &args).is_err());
850    }
851
852    #[test]
853    fn model_names_are_provider_safe_and_reversible() {
854        let mut registry = ToolRegistry::new();
855        registry.register(Arc::new(TestTool("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"))).unwrap();
856        let spec = registry.specs().pop().unwrap().function.name;
857        assert!(spec.len() <= 64);
858        assert!(spec
859            .bytes()
860            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
861        assert_eq!(registry.canonical_name(&spec), Some("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"));
862
863        let mut collision = ToolRegistry::new();
864        collision.register(Arc::new(TestTool("a.b"))).unwrap();
865        assert_eq!(model_tool_name("a.b"), "a_b_2e7336dc8eba87ef");
866        assert!(collision
867            .register(Arc::new(TestTool("a_b_2e7336dc8eba87ef")))
868            .is_err());
869    }
870
871    #[tokio::test]
872    async fn provider_junk_prefixes_resolve_once_at_the_registry_boundary() {
873        let mut registry = ToolRegistry::new();
874        registry
875            .register(Arc::new(TestTool("analyze_wallet")))
876            .unwrap();
877        let args = serde_json::json!({"items":[{"id":1}],"mode":"safe"});
878
879        assert_eq!(
880            registry
881                .execute_with_context("Notebookanalyze_wallet", &execution(), args.clone())
882                .await
883                .unwrap(),
884            args
885        );
886        assert!(registry
887            .execute_with_context("Listanalyze_wallets", &execution(), args.clone())
888            .await
889            .is_ok());
890        assert!(registry
891            .execute_with_context("namespace.analyze_wallet", &execution(), args)
892            .await
893            .is_err());
894    }
895
896    #[test]
897    fn invalid_schema_is_rejected_at_registration() {
898        struct Invalid;
899        #[async_trait]
900        impl Tool for Invalid {
901            fn name(&self) -> &str {
902                "invalid"
903            }
904            fn description(&self) -> &str {
905                "invalid"
906            }
907            fn parameters(&self) -> Value {
908                serde_json::json!({"type":"not-a-type"})
909            }
910            fn output_schema(&self) -> Value {
911                serde_json::json!({"type":"object"})
912            }
913            async fn call(&self, _: Value) -> Result<Value, String> {
914                Ok(Value::Null)
915            }
916        }
917        assert!(ToolRegistry::new().register(Arc::new(Invalid)).is_err());
918    }
919
920    #[tokio::test]
921    async fn successful_output_is_validated_before_materialization() {
922        struct InvalidOutput;
923        #[async_trait]
924        impl Tool for InvalidOutput {
925            fn name(&self) -> &str {
926                "invalid-output"
927            }
928            fn description(&self) -> &str {
929                "invalid output"
930            }
931            fn parameters(&self) -> Value {
932                serde_json::json!({"type":"object"})
933            }
934            fn output_schema(&self) -> Value {
935                serde_json::json!({"type":"object"})
936            }
937            async fn call(&self, _: Value) -> Result<Value, String> {
938                Ok(Value::String("bad".into()))
939            }
940        }
941        let mut registry = ToolRegistry::new();
942        registry.register(Arc::new(InvalidOutput)).unwrap();
943        assert!(registry
944            .execute_with_context("invalid-output", &execution(), serde_json::json!({}))
945            .await
946            .unwrap_err()
947            .contains("invalid tool output"));
948    }
949}