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                let canonical = canonical.to_ascii_lowercase();
374                let prefix_len = lower.len().checked_sub(canonical.len());
375                let plural_prefix_len = lower
376                    .strip_suffix('s')
377                    .and_then(|singular| singular.len().checked_sub(canonical.len()))
378                    .filter(|_| lower[..lower.len() - 1].ends_with(&canonical));
379                prefix_len
380                    .filter(|_| lower.ends_with(&canonical))
381                    .or(plural_prefix_len)
382                    .is_some_and(|len| {
383                        len > 0
384                            && name
385                                .as_bytes()
386                                .get(..len)
387                                .is_some_and(|prefix| prefix.iter().all(u8::is_ascii_alphabetic))
388                    })
389            })
390            .max_by_key(|canonical| canonical.len())
391            .map(String::as_str)
392    }
393
394    fn unknown_tool_error(&self, name: &str) -> String {
395        let available = if self.tools.is_empty() {
396            "(none registered)".to_string()
397        } else {
398            self.names().join(", ")
399        };
400        match self.suggest_name(name) {
401            Some(suggestion) => format!(
402                "unknown tool '{name}'. Did you mean '{suggestion}'? Call tools by their exact registered name. Available: {available}"
403            ),
404            None => format!(
405                "unknown tool '{name}'. Call one of the registered tools by exact name. Available: {available}"
406            ),
407        }
408    }
409
410    /// Validate, execute and validate the result of one tool call.
411    pub async fn execute_with_context(
412        &self,
413        name: &str,
414        context: &ToolExecutionContext,
415        args: Value,
416    ) -> Result<Value, String> {
417        if let Some(canonical) = self.canonical_name(name) {
418            self.validate_arguments(canonical, &args)?;
419            let tool = self
420                .tools
421                .get(canonical)
422                .ok_or_else(|| self.unknown_tool_error(canonical))?;
423            let result = tool.call_with_context(context, args).await;
424            let value = result?;
425            self.validate_output(canonical, &value)?;
426            return Ok(value);
427        }
428        Err(self.unknown_tool_error(name))
429    }
430
431    /// Internal name for an internal or model-facing name.
432    pub fn canonical_name<'a>(&'a self, name: &'a str) -> Option<&'a str> {
433        if self.tools.contains_key(name) {
434            return Some(name);
435        }
436        self.wire_names
437            .get(name)
438            .map(String::as_str)
439            .or_else(|| self.suggest_name(name))
440    }
441}
442
443/// Validate `value` against `schema`.
444pub fn validate_json_schema(schema: &Value, value: &Value) -> Result<(), String> {
445    validate_json_schema_value(schema, value, "tool arguments")
446}
447
448/// Check that `schema` is a valid JSON Schema.
449pub fn validate_json_schema_definition(schema: &Value) -> Result<(), String> {
450    jsonschema::validator_for(schema)
451        .map(|_| ())
452        .map_err(|error| format!("invalid JSON schema: {error}"))
453}
454
455/// Validate `value` against `schema`, labelling errors with `subject`.
456pub fn validate_json_schema_value(
457    schema: &Value,
458    value: &Value,
459    subject: &str,
460) -> Result<(), String> {
461    let validator = jsonschema::validator_for(schema)
462        .map_err(|error| format!("invalid JSON schema: {error}"))?;
463    validator
464        .validate(value)
465        .map_err(|error| format!("invalid {subject}: {error}"))
466}
467
468/// Model-facing tool name (dots become underscores).
469pub fn model_tool_name(internal: &str) -> String {
470    if !internal.is_empty()
471        && internal.len() <= 64
472        && internal
473            .bytes()
474            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
475    {
476        return internal.to_string();
477    }
478    let mut prefix = internal
479        .bytes()
480        .map(|byte| {
481            if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
482                byte as char
483            } else {
484                '_'
485            }
486        })
487        .take(47)
488        .collect::<String>();
489    if prefix.is_empty() {
490        prefix.push_str("tool");
491    }
492    let digest = format!("{:x}", Sha256::digest(internal.as_bytes()));
493    format!("{prefix}_{}", &digest[..16])
494}
495
496/// Helpers for implementing [`Tool`].
497pub mod support {
498    use serde::de::DeserializeOwned;
499    use serde_json::Value;
500
501    /// Provides the argument schema for a typed tool.
502    pub trait RawToolSchema {
503        /// JSON Schema for the arguments.
504        fn parameters() -> Value;
505    }
506
507    /// Deserialize a required argument.
508    pub fn extract_required<T: DeserializeOwned>(args: &Value, name: &str) -> Result<T, String> {
509        let value = args
510            .get(name)
511            .cloned()
512            .ok_or_else(|| format!("missing required argument '{name}'"))?;
513        serde_json::from_value(value).map_err(|error| format!("invalid argument '{name}': {error}"))
514    }
515
516    /// Deserialize an optional argument.
517    pub fn extract_optional<T: DeserializeOwned>(
518        args: &Value,
519        name: &str,
520    ) -> Result<Option<T>, String> {
521        match args.get(name) {
522            None | Some(Value::Null) => Ok(None),
523            Some(value) => serde_json::from_value(value.clone())
524                .map(Some)
525                .map_err(|error| format!("invalid argument '{name}': {error}")),
526        }
527    }
528}
529
530impl fmt::Debug for ToolRegistry {
531    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532        f.debug_struct("ToolRegistry")
533            .field("tools", &self.names())
534            .finish()
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn execution() -> ToolExecutionContext {
543        ToolExecutionContext {
544            request: crate::RequestContext {
545                tenant_id: "tenant".parse().unwrap(),
546                subject_id: "subject".parse().unwrap(),
547                roles: Default::default(),
548                locale: "en".into(),
549                request_id: "request".parse().unwrap(),
550                entitlements: Default::default(),
551            },
552            session_id: "session".parse().unwrap(),
553            run_id: "run".parse().unwrap(),
554            step: 1,
555            call_id: "call".parse().unwrap(),
556            source_event_seq: 1,
557            interaction_resolution: None,
558            cancellation: CancellationToken::default(),
559            deadline: Instant::now() + std::time::Duration::from_secs(1),
560        }
561    }
562
563    struct TestTool(&'static str);
564    #[async_trait]
565    impl Tool for TestTool {
566        fn name(&self) -> &str {
567            self.0
568        }
569        fn description(&self) -> &str {
570            "test"
571        }
572        fn parameters(&self) -> Value {
573            serde_json::json!({
574                "type":"object",
575                "required":["items","mode"],
576                "additionalProperties":false,
577                "properties":{
578                    "items":{"type":"array","items":{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}},
579                    "mode":{"enum":["safe","fast"]},
580                    "version":{"const":1},
581                    "choice":{"oneOf":[{"type":"string"},{"type":"number"}]}
582                }
583            })
584        }
585        fn output_schema(&self) -> Value {
586            serde_json::json!({"type":"object"})
587        }
588        async fn call(&self, args: Value) -> Result<Value, String> {
589            Ok(args)
590        }
591    }
592
593    #[tokio::test]
594    async fn registry_rejects_duplicates_and_validates_full_schema() {
595        let mut registry = ToolRegistry::new();
596        registry
597            .register(Arc::new(TestTool("nested.tool")))
598            .unwrap();
599        assert!(registry
600            .register(Arc::new(TestTool("nested.tool")))
601            .is_err());
602        let valid = serde_json::json!({"items":[{"id":1}],"mode":"safe","version":1,"choice":"x"});
603        assert_eq!(
604            registry
605                .execute_with_context("nested.tool", &execution(), valid.clone())
606                .await
607                .unwrap(),
608            valid
609        );
610        for invalid in [
611            serde_json::json!({"items":[{}],"mode":"safe"}),
612            serde_json::json!({"items":[{"id":1}],"mode":"unsafe"}),
613            serde_json::json!({"items":[{"id":1}],"mode":"safe","extra":true}),
614            serde_json::json!({"items":[{"id":1}],"mode":"safe","version":2}),
615            serde_json::json!({"items":[{"id":1}],"mode":"safe","choice":true}),
616        ] {
617            assert!(registry
618                .execute_with_context("nested.tool", &execution(), invalid)
619                .await
620                .is_err());
621        }
622    }
623
624    #[test]
625    fn model_names_are_provider_safe_and_reversible() {
626        let mut registry = ToolRegistry::new();
627        registry.register(Arc::new(TestTool("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"))).unwrap();
628        let spec = registry.specs().pop().unwrap().function.name;
629        assert!(spec.len() <= 64);
630        assert!(spec
631            .bytes()
632            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
633        assert_eq!(registry.canonical_name(&spec), Some("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"));
634
635        let mut collision = ToolRegistry::new();
636        collision.register(Arc::new(TestTool("a.b"))).unwrap();
637        assert_eq!(model_tool_name("a.b"), "a_b_2e7336dc8eba87ef");
638        assert!(collision
639            .register(Arc::new(TestTool("a_b_2e7336dc8eba87ef")))
640            .is_err());
641    }
642
643    #[tokio::test]
644    async fn provider_junk_prefixes_resolve_once_at_the_registry_boundary() {
645        let mut registry = ToolRegistry::new();
646        registry
647            .register(Arc::new(TestTool("analyze_wallet")))
648            .unwrap();
649        let args = serde_json::json!({"items":[{"id":1}],"mode":"safe"});
650
651        assert_eq!(
652            registry
653                .execute_with_context("Notebookanalyze_wallet", &execution(), args.clone())
654                .await
655                .unwrap(),
656            args
657        );
658        assert!(registry
659            .execute_with_context("Listanalyze_wallets", &execution(), args.clone())
660            .await
661            .is_ok());
662        assert!(registry
663            .execute_with_context("namespace.analyze_wallet", &execution(), args)
664            .await
665            .is_err());
666    }
667
668    #[test]
669    fn invalid_schema_is_rejected_at_registration() {
670        struct Invalid;
671        #[async_trait]
672        impl Tool for Invalid {
673            fn name(&self) -> &str {
674                "invalid"
675            }
676            fn description(&self) -> &str {
677                "invalid"
678            }
679            fn parameters(&self) -> Value {
680                serde_json::json!({"type":"not-a-type"})
681            }
682            fn output_schema(&self) -> Value {
683                serde_json::json!({"type":"object"})
684            }
685            async fn call(&self, _: Value) -> Result<Value, String> {
686                Ok(Value::Null)
687            }
688        }
689        assert!(ToolRegistry::new().register(Arc::new(Invalid)).is_err());
690    }
691
692    #[tokio::test]
693    async fn successful_output_is_validated_before_materialization() {
694        struct InvalidOutput;
695        #[async_trait]
696        impl Tool for InvalidOutput {
697            fn name(&self) -> &str {
698                "invalid-output"
699            }
700            fn description(&self) -> &str {
701                "invalid output"
702            }
703            fn parameters(&self) -> Value {
704                serde_json::json!({"type":"object"})
705            }
706            fn output_schema(&self) -> Value {
707                serde_json::json!({"type":"object"})
708            }
709            async fn call(&self, _: Value) -> Result<Value, String> {
710                Ok(Value::String("bad".into()))
711            }
712        }
713        let mut registry = ToolRegistry::new();
714        registry.register(Arc::new(InvalidOutput)).unwrap();
715        assert!(registry
716            .execute_with_context("invalid-output", &execution(), serde_json::json!({}))
717            .await
718            .unwrap_err()
719            .contains("invalid tool output"));
720    }
721}