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/// Static execution metadata for a tool.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct ToolMeta {
45    /// Who may call it.
46    pub surface: ToolSurface,
47    /// Provider-neutral cost units settled for this operation.
48    pub cost_units: u64,
49    /// Per-call timeout.
50    pub timeout_secs: u64,
51    /// Whether the tool is part of the platform core (never narrowed away).
52    pub core: bool,
53    /// Concurrency class.
54    pub concurrency: ToolConcurrency,
55    /// Mutating tools must cross the runtime's normal confirmation hook even
56    /// when a profile forgot to list them explicitly.
57    pub requires_confirmation: bool,
58}
59
60/// Hierarchical cancellation flag. Cancelling a parent cancels every child;
61/// [`cancelled`](Self::cancelled) resolves without polling.
62#[derive(Debug, Clone, Default)]
63pub struct CancellationToken(Arc<CancellationState>);
64
65#[derive(Debug, Default)]
66struct CancellationState {
67    cancelled: AtomicBool,
68    notify: tokio::sync::Notify,
69    parent: Option<CancellationToken>,
70}
71
72impl CancellationToken {
73    /// Cancel this token and every child.
74    pub fn cancel(&self) {
75        self.0.cancelled.store(true, Ordering::Release);
76        self.0.notify.notify_waiters();
77    }
78    /// Whether this token or an ancestor was cancelled.
79    pub fn is_cancelled(&self) -> bool {
80        self.0.cancelled.load(Ordering::Acquire)
81            || self
82                .0
83                .parent
84                .as_ref()
85                .is_some_and(CancellationToken::is_cancelled)
86    }
87    /// A child token cancelled with its parent.
88    pub fn child(&self) -> Self {
89        Self(Arc::new(CancellationState {
90            cancelled: AtomicBool::new(false),
91            notify: tokio::sync::Notify::new(),
92            parent: Some(self.clone()),
93        }))
94    }
95    /// Resolves once this token or any ancestor is cancelled.
96    pub fn cancelled(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
97        Box::pin(async move {
98            let notified = self.0.notify.notified();
99            tokio::pin!(notified);
100            notified.as_mut().enable();
101            if self.is_cancelled() {
102                return;
103            }
104            match &self.0.parent {
105                Some(parent) => tokio::select! {
106                    _ = notified => {}
107                    _ = parent.cancelled() => {}
108                },
109                None => notified.await,
110            }
111        })
112    }
113}
114
115/// Identity, position and controls for one tool execution.
116#[derive(Debug, Clone)]
117pub struct ToolExecutionContext {
118    /// The originating request.
119    pub request: af_context::RequestContext,
120    /// Session this record belongs to.
121    pub session_id: SessionId,
122    /// Run this record belongs to.
123    pub run_id: RunId,
124    /// 1-based step number inside the Turn.
125    pub step: u32,
126    /// Tool call this record refers to.
127    pub call_id: ToolCallId,
128    /// Sequence of the Session event this was derived from.
129    pub source_event_seq: u64,
130    /// Resolution of the interaction that unblocked this call, if any.
131    pub interaction_resolution: Option<af_agent_session::InteractionResolution>,
132    /// Cancelled when the Run or step is abandoned.
133    pub cancellation: CancellationToken,
134    /// Latest time by which the work must finish.
135    pub deadline: Instant,
136}
137
138impl Default for ToolMeta {
139    fn default() -> Self {
140        Self {
141            surface: ToolSurface::Llm,
142            cost_units: 1,
143            timeout_secs: 15,
144            core: false,
145            concurrency: ToolConcurrency::Exclusive,
146            requires_confirmation: false,
147        }
148    }
149}
150
151/// A callable tool. `parameters` is a JSON-Schema object describing the args.
152#[async_trait]
153pub trait Tool: Send + Sync {
154    /// Stable internal name.
155    fn name(&self) -> &str;
156    /// Implementation version pinned by Profile revisions.
157    fn implementation_version(&self) -> &str {
158        ""
159    }
160    /// Model-facing description.
161    fn description(&self) -> &str;
162    /// JSON Schema for the arguments.
163    fn parameters(&self) -> Value;
164    /// JSON Schema for the result.
165    fn output_schema(&self) -> Value;
166    /// Execution metadata.
167    fn meta(&self) -> ToolMeta {
168        ToolMeta::default()
169    }
170
171    /// Run the tool. On success return a JSON value; on failure return a short
172    /// error string (surfaced back to the model as `{"error": ...}` so it can
173    /// recover) — mirrors the Python "success → dict / failure → {error}" rule.
174    async fn call(&self, args: Value) -> Result<Value, String>;
175    /// Like [`call`](Self::call) with the execution context; the default ignores the context.
176    async fn call_with_context(
177        &self,
178        _context: &ToolExecutionContext,
179        args: Value,
180    ) -> Result<Value, String> {
181        self.call(args).await
182    }
183}
184
185/// Registry of tools available to an agent.
186#[derive(Default, Clone)]
187pub struct ToolRegistry {
188    tools: HashMap<String, Arc<dyn Tool>>,
189    wire_names: HashMap<String, String>,
190    validators: HashMap<String, Arc<jsonschema::Validator>>,
191    output_validators: HashMap<String, Arc<jsonschema::Validator>>,
192}
193
194impl ToolRegistry {
195    /// An empty registry.
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Register a tool, compiling its schemas; duplicate or invalid tools are rejected.
201    pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<&mut Self, String> {
202        let name = tool.name().to_string();
203        if self.tools.contains_key(&name) {
204            return Err(format!("duplicate tool '{name}'"));
205        }
206        let wire_name = model_tool_name(&name);
207        if self.wire_names.contains_key(&wire_name)
208            || (wire_name != name && self.tools.contains_key(&wire_name))
209            || self.wire_names.contains_key(&name)
210        {
211            return Err(format!(
212                "tool name '{name}' collides on provider name '{wire_name}'"
213            ));
214        }
215        let validator = jsonschema::validator_for(&tool.parameters())
216            .map_err(|error| format!("invalid schema for tool '{name}': {error}"))?;
217        let output_validator = jsonschema::validator_for(&tool.output_schema())
218            .map_err(|error| format!("invalid output schema for tool '{name}': {error}"))?;
219        self.tools.insert(name.clone(), tool);
220        self.wire_names.insert(wire_name, name.clone());
221        self.validators.insert(name.clone(), Arc::new(validator));
222        self.output_validators
223            .insert(name, Arc::new(output_validator));
224        Ok(self)
225    }
226
227    /// Merge another registry; conflicting names are rejected.
228    pub fn extend(&mut self, other: &Self) -> Result<(), String> {
229        for tool in other.tools.values() {
230            self.register(Arc::clone(tool))?;
231        }
232        Ok(())
233    }
234
235    /// Whether no tools are registered.
236    pub fn is_empty(&self) -> bool {
237        self.tools.is_empty()
238    }
239
240    /// Number of registered tools.
241    pub fn len(&self) -> usize {
242        self.tools.len()
243    }
244
245    /// Whether `name` (internal or model form) is registered.
246    pub fn contains(&self, name: &str) -> bool {
247        self.tools.contains_key(name)
248    }
249
250    /// Look up a tool by internal or model name.
251    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
252        self.canonical_name(name)
253            .and_then(|name| self.tools.get(name))
254            .cloned()
255    }
256
257    /// Validate arguments against the tool's schema.
258    pub fn validate_arguments(&self, name: &str, arguments: &Value) -> Result<(), String> {
259        let name = self
260            .canonical_name(name)
261            .ok_or_else(|| self.unknown_tool_error(name))?;
262        self.validators
263            .get(name)
264            .ok_or_else(|| self.unknown_tool_error(name))?
265            .validate(arguments)
266            .map_err(|error| format!("invalid tool arguments: {error}"))
267    }
268
269    /// Validate a result against the tool's output schema.
270    pub fn validate_output(&self, name: &str, output: &Value) -> Result<(), String> {
271        let name = self
272            .canonical_name(name)
273            .ok_or_else(|| self.unknown_tool_error(name))?;
274        self.output_validators
275            .get(name)
276            .ok_or_else(|| self.unknown_tool_error(name))?
277            .validate(output)
278            .map_err(|error| format!("invalid tool output: {error}"))
279    }
280
281    /// Stable tool names for validation, diagnostics, and capability policy.
282    pub fn names(&self) -> Vec<&str> {
283        let mut names = self.tools.keys().map(String::as_str).collect::<Vec<_>>();
284        names.sort_unstable();
285        names
286    }
287
288    /// Tools whose metadata requires confirmation.
289    pub fn confirmation_required_names(&self) -> impl Iterator<Item = &str> {
290        self.tools
291            .values()
292            .filter(|tool| tool.meta().requires_confirmation)
293            .map(|tool| tool.name())
294    }
295
296    /// Return a registry containing only explicitly allowed tools.
297    pub fn filtered<'a>(&self, allowed: impl IntoIterator<Item = &'a str>) -> Self {
298        let mut filtered = Self::new();
299        for name in allowed {
300            if let Some(tool) = self.tools.get(name) {
301                // A subset of an already-validated registry cannot introduce a
302                // conflict, so a failure here is unreachable and safely ignored.
303                let _ = filtered.register(Arc::clone(tool));
304            }
305        }
306        filtered
307    }
308
309    /// LLM-facing tool specs for the `tools` field of a completion request.
310    pub fn specs(&self) -> Vec<LlmTool> {
311        let mut specs = self
312            .tools
313            .values()
314            .filter(|tool| tool.meta().surface == ToolSurface::Llm)
315            .map(|t| LlmTool::function(model_tool_name(t.name()), t.description(), t.parameters()))
316            .collect::<Vec<_>>();
317        specs.sort_by(|left, right| left.function.name.cmp(&right.function.name));
318        specs
319    }
320
321    /// Stable implementation and execution contract persisted by profile revisions.
322    pub fn runtime_manifest(&self) -> Result<Vec<Value>, String> {
323        self.names()
324            .into_iter()
325            .map(|name| {
326                let tool = self
327                    .tools
328                    .get(name)
329                    .ok_or_else(|| self.unknown_tool_error(name))?;
330                let version = tool.implementation_version().trim();
331                if version.is_empty() {
332                    return Err(format!(
333                        "tool '{name}' requires a stable implementation version"
334                    ));
335                }
336                let meta = tool.meta();
337                Ok(serde_json::json!({
338                    "name": name,
339                    "implementation_version": version,
340                    "description": tool.description(),
341                    "parameters": tool.parameters(),
342                    "output_schema": tool.output_schema(),
343                    "surface": match meta.surface { ToolSurface::Llm => "llm", ToolSurface::Chassis => "chassis" },
344                    "timeout_secs": meta.timeout_secs,
345                    "concurrency": match meta.concurrency { ToolConcurrency::Concurrent => "concurrent", ToolConcurrency::Exclusive => "exclusive" },
346                    "cost_units": meta.cost_units,
347                    "core": meta.core,
348                    "requires_confirmation": meta.requires_confirmation,
349                }))
350            })
351            .collect()
352    }
353
354    /// Best-effort canonical name for case mistakes or an alphabetic junk
355    /// prefix glued to a registered tool name.
356    pub fn suggest_name(&self, name: &str) -> Option<&str> {
357        let name = name.trim();
358        if name.is_empty() || self.tools.contains_key(name) {
359            return None;
360        }
361        if let Some((canonical, _)) = self
362            .tools
363            .iter()
364            .find(|(canonical, _)| canonical.eq_ignore_ascii_case(name))
365        {
366            return Some(canonical);
367        }
368
369        let lower = name.to_ascii_lowercase();
370        self.tools
371            .keys()
372            .filter(|canonical| {
373                name.len() > canonical.len() && lower.ends_with(&canonical.to_ascii_lowercase())
374            })
375            .max_by_key(|canonical| canonical.len())
376            .map(String::as_str)
377    }
378
379    fn unknown_tool_error(&self, name: &str) -> String {
380        let available = if self.tools.is_empty() {
381            "(none registered)".to_string()
382        } else {
383            self.names().join(", ")
384        };
385        match self.suggest_name(name) {
386            Some(suggestion) => format!(
387                "unknown tool '{name}'. Did you mean '{suggestion}'? Call tools by their exact registered name. Available: {available}"
388            ),
389            None => format!(
390                "unknown tool '{name}'. Call one of the registered tools by exact name. Available: {available}"
391            ),
392        }
393    }
394
395    /// Validate, execute and validate the result of one tool call.
396    pub async fn execute_with_context(
397        &self,
398        name: &str,
399        context: &ToolExecutionContext,
400        args: Value,
401    ) -> Result<Value, String> {
402        if let Some(canonical) = self.canonical_name(name) {
403            self.validate_arguments(canonical, &args)?;
404            let tool = self
405                .tools
406                .get(canonical)
407                .ok_or_else(|| self.unknown_tool_error(canonical))?;
408            let result = tool.call_with_context(context, args).await;
409            let value = result?;
410            self.validate_output(canonical, &value)?;
411            return Ok(value);
412        }
413        Err(self.unknown_tool_error(name))
414    }
415
416    /// Internal name for an internal or model-facing name.
417    pub fn canonical_name<'a>(&'a self, name: &'a str) -> Option<&'a str> {
418        if self.tools.contains_key(name) {
419            return Some(name);
420        }
421        self.wire_names.get(name).map(String::as_str)
422    }
423}
424
425/// Validate `value` against `schema`.
426pub fn validate_json_schema(schema: &Value, value: &Value) -> Result<(), String> {
427    validate_json_schema_value(schema, value, "tool arguments")
428}
429
430/// Check that `schema` is a valid JSON Schema.
431pub fn validate_json_schema_definition(schema: &Value) -> Result<(), String> {
432    jsonschema::validator_for(schema)
433        .map(|_| ())
434        .map_err(|error| format!("invalid JSON schema: {error}"))
435}
436
437/// Validate `value` against `schema`, labelling errors with `subject`.
438pub fn validate_json_schema_value(
439    schema: &Value,
440    value: &Value,
441    subject: &str,
442) -> Result<(), String> {
443    let validator = jsonschema::validator_for(schema)
444        .map_err(|error| format!("invalid JSON schema: {error}"))?;
445    validator
446        .validate(value)
447        .map_err(|error| format!("invalid {subject}: {error}"))
448}
449
450/// Model-facing tool name (dots become underscores).
451pub fn model_tool_name(internal: &str) -> String {
452    if !internal.is_empty()
453        && internal.len() <= 64
454        && internal
455            .bytes()
456            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
457    {
458        return internal.to_string();
459    }
460    let mut prefix = internal
461        .bytes()
462        .map(|byte| {
463            if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
464                byte as char
465            } else {
466                '_'
467            }
468        })
469        .take(47)
470        .collect::<String>();
471    if prefix.is_empty() {
472        prefix.push_str("tool");
473    }
474    let digest = format!("{:x}", Sha256::digest(internal.as_bytes()));
475    format!("{prefix}_{}", &digest[..16])
476}
477
478/// Helpers for implementing [`Tool`].
479pub mod support {
480    use serde::de::DeserializeOwned;
481    use serde_json::Value;
482
483    /// Provides the argument schema for a typed tool.
484    pub trait RawToolSchema {
485        /// JSON Schema for the arguments.
486        fn parameters() -> Value;
487    }
488
489    /// Deserialize a required argument.
490    pub fn extract_required<T: DeserializeOwned>(args: &Value, name: &str) -> Result<T, String> {
491        let value = args
492            .get(name)
493            .cloned()
494            .ok_or_else(|| format!("missing required argument '{name}'"))?;
495        serde_json::from_value(value).map_err(|error| format!("invalid argument '{name}': {error}"))
496    }
497
498    /// Deserialize an optional argument.
499    pub fn extract_optional<T: DeserializeOwned>(
500        args: &Value,
501        name: &str,
502    ) -> Result<Option<T>, String> {
503        match args.get(name) {
504            None | Some(Value::Null) => Ok(None),
505            Some(value) => serde_json::from_value(value.clone())
506                .map(Some)
507                .map_err(|error| format!("invalid argument '{name}': {error}")),
508        }
509    }
510}
511
512impl fmt::Debug for ToolRegistry {
513    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
514        f.debug_struct("ToolRegistry")
515            .field("tools", &self.names())
516            .finish()
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn execution() -> ToolExecutionContext {
525        ToolExecutionContext {
526            request: crate::RequestContext {
527                tenant_id: "tenant".parse().unwrap(),
528                subject_id: "subject".parse().unwrap(),
529                roles: Default::default(),
530                locale: "en".into(),
531                request_id: "request".parse().unwrap(),
532                entitlements: Default::default(),
533            },
534            session_id: "session".parse().unwrap(),
535            run_id: "run".parse().unwrap(),
536            step: 1,
537            call_id: "call".parse().unwrap(),
538            source_event_seq: 1,
539            interaction_resolution: None,
540            cancellation: CancellationToken::default(),
541            deadline: Instant::now() + std::time::Duration::from_secs(1),
542        }
543    }
544
545    struct TestTool(&'static str);
546    #[async_trait]
547    impl Tool for TestTool {
548        fn name(&self) -> &str {
549            self.0
550        }
551        fn description(&self) -> &str {
552            "test"
553        }
554        fn parameters(&self) -> Value {
555            serde_json::json!({
556                "type":"object",
557                "required":["items","mode"],
558                "additionalProperties":false,
559                "properties":{
560                    "items":{"type":"array","items":{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}},
561                    "mode":{"enum":["safe","fast"]},
562                    "version":{"const":1},
563                    "choice":{"oneOf":[{"type":"string"},{"type":"number"}]}
564                }
565            })
566        }
567        fn output_schema(&self) -> Value {
568            serde_json::json!({"type":"object"})
569        }
570        async fn call(&self, args: Value) -> Result<Value, String> {
571            Ok(args)
572        }
573    }
574
575    #[tokio::test]
576    async fn registry_rejects_duplicates_and_validates_full_schema() {
577        let mut registry = ToolRegistry::new();
578        registry
579            .register(Arc::new(TestTool("nested.tool")))
580            .unwrap();
581        assert!(registry
582            .register(Arc::new(TestTool("nested.tool")))
583            .is_err());
584        let valid = serde_json::json!({"items":[{"id":1}],"mode":"safe","version":1,"choice":"x"});
585        assert_eq!(
586            registry
587                .execute_with_context("nested.tool", &execution(), valid.clone())
588                .await
589                .unwrap(),
590            valid
591        );
592        for invalid in [
593            serde_json::json!({"items":[{}],"mode":"safe"}),
594            serde_json::json!({"items":[{"id":1}],"mode":"unsafe"}),
595            serde_json::json!({"items":[{"id":1}],"mode":"safe","extra":true}),
596            serde_json::json!({"items":[{"id":1}],"mode":"safe","version":2}),
597            serde_json::json!({"items":[{"id":1}],"mode":"safe","choice":true}),
598        ] {
599            assert!(registry
600                .execute_with_context("nested.tool", &execution(), invalid)
601                .await
602                .is_err());
603        }
604    }
605
606    #[test]
607    fn model_names_are_provider_safe_and_reversible() {
608        let mut registry = ToolRegistry::new();
609        registry.register(Arc::new(TestTool("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"))).unwrap();
610        let spec = registry.specs().pop().unwrap().function.name;
611        assert!(spec.len() <= 64);
612        assert!(spec
613            .bytes()
614            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
615        assert_eq!(registry.canonical_name(&spec), Some("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"));
616
617        let mut collision = ToolRegistry::new();
618        collision.register(Arc::new(TestTool("a.b"))).unwrap();
619        assert_eq!(model_tool_name("a.b"), "a_b_2e7336dc8eba87ef");
620        assert!(collision
621            .register(Arc::new(TestTool("a_b_2e7336dc8eba87ef")))
622            .is_err());
623    }
624
625    #[test]
626    fn invalid_schema_is_rejected_at_registration() {
627        struct Invalid;
628        #[async_trait]
629        impl Tool for Invalid {
630            fn name(&self) -> &str {
631                "invalid"
632            }
633            fn description(&self) -> &str {
634                "invalid"
635            }
636            fn parameters(&self) -> Value {
637                serde_json::json!({"type":"not-a-type"})
638            }
639            fn output_schema(&self) -> Value {
640                serde_json::json!({"type":"object"})
641            }
642            async fn call(&self, _: Value) -> Result<Value, String> {
643                Ok(Value::Null)
644            }
645        }
646        assert!(ToolRegistry::new().register(Arc::new(Invalid)).is_err());
647    }
648
649    #[tokio::test]
650    async fn successful_output_is_validated_before_materialization() {
651        struct InvalidOutput;
652        #[async_trait]
653        impl Tool for InvalidOutput {
654            fn name(&self) -> &str {
655                "invalid-output"
656            }
657            fn description(&self) -> &str {
658                "invalid output"
659            }
660            fn parameters(&self) -> Value {
661                serde_json::json!({"type":"object"})
662            }
663            fn output_schema(&self) -> Value {
664                serde_json::json!({"type":"object"})
665            }
666            async fn call(&self, _: Value) -> Result<Value, String> {
667                Ok(Value::String("bad".into()))
668            }
669        }
670        let mut registry = ToolRegistry::new();
671        registry.register(Arc::new(InvalidOutput)).unwrap();
672        assert!(registry
673            .execute_with_context("invalid-output", &execution(), serde_json::json!({}))
674            .await
675            .unwrap_err()
676            .contains("invalid tool output"));
677    }
678}