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
138/// A chassis tool call derived from a successful tool result.
139#[derive(Debug, Clone, PartialEq)]
140pub struct ToolCompletionAction {
141    /// Registered tool to call through the normal authorization boundary.
142    pub tool: String,
143    /// Arguments validated against that tool's schema.
144    pub arguments: Value,
145}
146
147impl Default for ToolMeta {
148    fn default() -> Self {
149        Self {
150            surface: ToolSurface::Llm,
151            cost_units: 1,
152            timeout_secs: 15,
153            core: false,
154            concurrency: ToolConcurrency::Exclusive,
155            requires_confirmation: false,
156        }
157    }
158}
159
160/// A callable tool. `parameters` is a JSON-Schema object describing the args.
161#[async_trait]
162pub trait Tool: Send + Sync {
163    /// Stable internal name.
164    fn name(&self) -> &str;
165    /// Implementation version pinned by Profile revisions.
166    fn implementation_version(&self) -> &str {
167        ""
168    }
169    /// Model-facing description.
170    fn description(&self) -> &str;
171    /// JSON Schema for the arguments.
172    fn parameters(&self) -> Value;
173    /// JSON Schema for the result.
174    fn output_schema(&self) -> Value;
175    /// Execution metadata.
176    fn meta(&self) -> ToolMeta {
177        ToolMeta::default()
178    }
179
180    /// Run the tool. On success return a JSON value; on failure return a short
181    /// error string (surfaced back to the model as `{"error": ...}` so it can
182    /// recover) — mirrors the Python "success → dict / failure → {error}" rule.
183    async fn call(&self, args: Value) -> Result<Value, String>;
184    /// Like [`call`](Self::call) with the execution context; the default ignores the context.
185    async fn call_with_context(
186        &self,
187        _context: &ToolExecutionContext,
188        args: Value,
189    ) -> Result<Value, String> {
190        self.call(args).await
191    }
192    /// Optionally request a chassis action after a successful validated result.
193    /// The runtime persists and authorizes the returned call before proceeding.
194    fn completion_action(&self, _result: &Value) -> Option<ToolCompletionAction> {
195        None
196    }
197}
198
199/// Registry of tools available to an agent.
200#[derive(Default, Clone)]
201pub struct ToolRegistry {
202    tools: HashMap<String, Arc<dyn Tool>>,
203    wire_names: HashMap<String, String>,
204    validators: HashMap<String, Arc<jsonschema::Validator>>,
205    output_validators: HashMap<String, Arc<jsonschema::Validator>>,
206}
207
208impl ToolRegistry {
209    /// An empty registry.
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Register a tool, compiling its schemas; duplicate or invalid tools are rejected.
215    pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<&mut Self, String> {
216        let name = tool.name().to_string();
217        if self.tools.contains_key(&name) {
218            return Err(format!("duplicate tool '{name}'"));
219        }
220        let wire_name = model_tool_name(&name);
221        if self.wire_names.contains_key(&wire_name)
222            || (wire_name != name && self.tools.contains_key(&wire_name))
223            || self.wire_names.contains_key(&name)
224        {
225            return Err(format!(
226                "tool name '{name}' collides on provider name '{wire_name}'"
227            ));
228        }
229        let validator = jsonschema::validator_for(&tool.parameters())
230            .map_err(|error| format!("invalid schema for tool '{name}': {error}"))?;
231        let output_validator = jsonschema::validator_for(&tool.output_schema())
232            .map_err(|error| format!("invalid output schema for tool '{name}': {error}"))?;
233        self.tools.insert(name.clone(), tool);
234        self.wire_names.insert(wire_name, name.clone());
235        self.validators.insert(name.clone(), Arc::new(validator));
236        self.output_validators
237            .insert(name, Arc::new(output_validator));
238        Ok(self)
239    }
240
241    /// Merge another registry; conflicting names are rejected.
242    pub fn extend(&mut self, other: &Self) -> Result<(), String> {
243        for tool in other.tools.values() {
244            self.register(Arc::clone(tool))?;
245        }
246        Ok(())
247    }
248
249    /// Whether no tools are registered.
250    pub fn is_empty(&self) -> bool {
251        self.tools.is_empty()
252    }
253
254    /// Number of registered tools.
255    pub fn len(&self) -> usize {
256        self.tools.len()
257    }
258
259    /// Whether `name` (internal or model form) is registered.
260    pub fn contains(&self, name: &str) -> bool {
261        self.tools.contains_key(name)
262    }
263
264    /// Look up a tool by internal or model name.
265    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
266        self.canonical_name(name)
267            .and_then(|name| self.tools.get(name))
268            .cloned()
269    }
270
271    /// Validate arguments against the tool's schema.
272    pub fn validate_arguments(&self, name: &str, arguments: &Value) -> Result<(), String> {
273        let name = self
274            .canonical_name(name)
275            .ok_or_else(|| self.unknown_tool_error(name))?;
276        self.validators
277            .get(name)
278            .ok_or_else(|| self.unknown_tool_error(name))?
279            .validate(arguments)
280            .map_err(|error| format!("invalid tool arguments: {error}"))
281    }
282
283    /// Validate a result against the tool's output schema.
284    pub fn validate_output(&self, name: &str, output: &Value) -> Result<(), String> {
285        let name = self
286            .canonical_name(name)
287            .ok_or_else(|| self.unknown_tool_error(name))?;
288        self.output_validators
289            .get(name)
290            .ok_or_else(|| self.unknown_tool_error(name))?
291            .validate(output)
292            .map_err(|error| format!("invalid tool output: {error}"))
293    }
294
295    /// Stable tool names for validation, diagnostics, and capability policy.
296    pub fn names(&self) -> Vec<&str> {
297        let mut names = self.tools.keys().map(String::as_str).collect::<Vec<_>>();
298        names.sort_unstable();
299        names
300    }
301
302    /// Tools whose metadata requires confirmation.
303    pub fn confirmation_required_names(&self) -> impl Iterator<Item = &str> {
304        self.tools
305            .values()
306            .filter(|tool| tool.meta().requires_confirmation)
307            .map(|tool| tool.name())
308    }
309
310    /// Return a registry containing only explicitly allowed tools.
311    pub fn filtered<'a>(&self, allowed: impl IntoIterator<Item = &'a str>) -> Self {
312        let mut filtered = Self::new();
313        for name in allowed {
314            if let Some(tool) = self.tools.get(name) {
315                // A subset of an already-validated registry cannot introduce a
316                // conflict, so a failure here is unreachable and safely ignored.
317                let _ = filtered.register(Arc::clone(tool));
318            }
319        }
320        filtered
321    }
322
323    /// LLM-facing tool specs for the `tools` field of a completion request.
324    pub fn specs(&self) -> Vec<LlmTool> {
325        let mut specs = self
326            .tools
327            .values()
328            .filter(|tool| tool.meta().surface == ToolSurface::Llm)
329            .map(|t| LlmTool::function(model_tool_name(t.name()), t.description(), t.parameters()))
330            .collect::<Vec<_>>();
331        specs.sort_by(|left, right| left.function.name.cmp(&right.function.name));
332        specs
333    }
334
335    /// Stable implementation and execution contract persisted by profile revisions.
336    pub fn runtime_manifest(&self) -> Result<Vec<Value>, String> {
337        self.names()
338            .into_iter()
339            .map(|name| {
340                let tool = self
341                    .tools
342                    .get(name)
343                    .ok_or_else(|| self.unknown_tool_error(name))?;
344                let version = tool.implementation_version().trim();
345                if version.is_empty() {
346                    return Err(format!(
347                        "tool '{name}' requires a stable implementation version"
348                    ));
349                }
350                let meta = tool.meta();
351                Ok(serde_json::json!({
352                    "name": name,
353                    "implementation_version": version,
354                    "description": tool.description(),
355                    "parameters": tool.parameters(),
356                    "output_schema": tool.output_schema(),
357                    "surface": match meta.surface { ToolSurface::Llm => "llm", ToolSurface::Chassis => "chassis" },
358                    "timeout_secs": meta.timeout_secs,
359                    "concurrency": match meta.concurrency { ToolConcurrency::Concurrent => "concurrent", ToolConcurrency::Exclusive => "exclusive" },
360                    "cost_units": meta.cost_units,
361                    "core": meta.core,
362                    "requires_confirmation": meta.requires_confirmation,
363                }))
364            })
365            .collect()
366    }
367
368    /// Best-effort canonical name for case mistakes or an alphabetic junk
369    /// prefix glued to a registered tool name.
370    pub fn suggest_name(&self, name: &str) -> Option<&str> {
371        let name = name.trim();
372        if name.is_empty() || self.tools.contains_key(name) {
373            return None;
374        }
375        if let Some((canonical, _)) = self
376            .tools
377            .iter()
378            .find(|(canonical, _)| canonical.eq_ignore_ascii_case(name))
379        {
380            return Some(canonical);
381        }
382
383        let lower = name.to_ascii_lowercase();
384        self.tools
385            .keys()
386            .filter(|canonical| {
387                let canonical = canonical.to_ascii_lowercase();
388                let prefix_len = lower.len().checked_sub(canonical.len());
389                let plural_prefix_len = lower
390                    .strip_suffix('s')
391                    .and_then(|singular| singular.len().checked_sub(canonical.len()))
392                    .filter(|_| lower[..lower.len() - 1].ends_with(&canonical));
393                prefix_len
394                    .filter(|_| lower.ends_with(&canonical))
395                    .or(plural_prefix_len)
396                    .is_some_and(|len| {
397                        len > 0
398                            && name
399                                .as_bytes()
400                                .get(..len)
401                                .is_some_and(|prefix| prefix.iter().all(u8::is_ascii_alphabetic))
402                    })
403            })
404            .max_by_key(|canonical| canonical.len())
405            .map(String::as_str)
406    }
407
408    fn unknown_tool_error(&self, name: &str) -> String {
409        let available = if self.tools.is_empty() {
410            "(none registered)".to_string()
411        } else {
412            self.names().join(", ")
413        };
414        match self.suggest_name(name) {
415            Some(suggestion) => format!(
416                "unknown tool '{name}'. Did you mean '{suggestion}'? Call tools by their exact registered name. Available: {available}"
417            ),
418            None => format!(
419                "unknown tool '{name}'. Call one of the registered tools by exact name. Available: {available}"
420            ),
421        }
422    }
423
424    /// Validate, execute and validate the result of one tool call.
425    pub async fn execute_with_context(
426        &self,
427        name: &str,
428        context: &ToolExecutionContext,
429        args: Value,
430    ) -> Result<Value, String> {
431        if let Some(canonical) = self.canonical_name(name) {
432            self.validate_arguments(canonical, &args)?;
433            let tool = self
434                .tools
435                .get(canonical)
436                .ok_or_else(|| self.unknown_tool_error(canonical))?;
437            let result = tool.call_with_context(context, args).await;
438            let value = result?;
439            self.validate_output(canonical, &value)?;
440            return Ok(value);
441        }
442        Err(self.unknown_tool_error(name))
443    }
444
445    /// Internal name for an internal or model-facing name.
446    pub fn canonical_name<'a>(&'a self, name: &'a str) -> Option<&'a str> {
447        if self.tools.contains_key(name) {
448            return Some(name);
449        }
450        self.wire_names
451            .get(name)
452            .map(String::as_str)
453            .or_else(|| self.suggest_name(name))
454    }
455}
456
457/// Validate `value` against `schema`.
458pub fn validate_json_schema(schema: &Value, value: &Value) -> Result<(), String> {
459    validate_json_schema_value(schema, value, "tool arguments")
460}
461
462/// Check that `schema` is a valid JSON Schema.
463pub fn validate_json_schema_definition(schema: &Value) -> Result<(), String> {
464    jsonschema::validator_for(schema)
465        .map(|_| ())
466        .map_err(|error| format!("invalid JSON schema: {error}"))
467}
468
469/// Validate `value` against `schema`, labelling errors with `subject`.
470pub fn validate_json_schema_value(
471    schema: &Value,
472    value: &Value,
473    subject: &str,
474) -> Result<(), String> {
475    let validator = jsonschema::validator_for(schema)
476        .map_err(|error| format!("invalid JSON schema: {error}"))?;
477    validator
478        .validate(value)
479        .map_err(|error| format!("invalid {subject}: {error}"))
480}
481
482/// Model-facing tool name (dots become underscores).
483pub fn model_tool_name(internal: &str) -> String {
484    if !internal.is_empty()
485        && internal.len() <= 64
486        && internal
487            .bytes()
488            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
489    {
490        return internal.to_string();
491    }
492    let mut prefix = internal
493        .bytes()
494        .map(|byte| {
495            if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
496                byte as char
497            } else {
498                '_'
499            }
500        })
501        .take(47)
502        .collect::<String>();
503    if prefix.is_empty() {
504        prefix.push_str("tool");
505    }
506    let digest = format!("{:x}", Sha256::digest(internal.as_bytes()));
507    format!("{prefix}_{}", &digest[..16])
508}
509
510/// Helpers for implementing [`Tool`].
511pub mod support {
512    use serde::de::DeserializeOwned;
513    use serde_json::Value;
514
515    /// Provides the argument schema for a typed tool.
516    pub trait RawToolSchema {
517        /// JSON Schema for the arguments.
518        fn parameters() -> Value;
519    }
520
521    /// Deserialize a required argument.
522    pub fn extract_required<T: DeserializeOwned>(args: &Value, name: &str) -> Result<T, String> {
523        let value = args
524            .get(name)
525            .cloned()
526            .ok_or_else(|| format!("missing required argument '{name}'"))?;
527        serde_json::from_value(value).map_err(|error| format!("invalid argument '{name}': {error}"))
528    }
529
530    /// Deserialize an optional argument.
531    pub fn extract_optional<T: DeserializeOwned>(
532        args: &Value,
533        name: &str,
534    ) -> Result<Option<T>, String> {
535        match args.get(name) {
536            None | Some(Value::Null) => Ok(None),
537            Some(value) => serde_json::from_value(value.clone())
538                .map(Some)
539                .map_err(|error| format!("invalid argument '{name}': {error}")),
540        }
541    }
542}
543
544impl fmt::Debug for ToolRegistry {
545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546        f.debug_struct("ToolRegistry")
547            .field("tools", &self.names())
548            .finish()
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    fn execution() -> ToolExecutionContext {
557        ToolExecutionContext {
558            request: crate::RequestContext {
559                tenant_id: "tenant".parse().unwrap(),
560                subject_id: "subject".parse().unwrap(),
561                roles: Default::default(),
562                locale: "en".into(),
563                request_id: "request".parse().unwrap(),
564                entitlements: Default::default(),
565            },
566            session_id: "session".parse().unwrap(),
567            run_id: "run".parse().unwrap(),
568            step: 1,
569            call_id: "call".parse().unwrap(),
570            source_event_seq: 1,
571            interaction_resolution: None,
572            cancellation: CancellationToken::default(),
573            deadline: Instant::now() + std::time::Duration::from_secs(1),
574        }
575    }
576
577    struct TestTool(&'static str);
578    #[async_trait]
579    impl Tool for TestTool {
580        fn name(&self) -> &str {
581            self.0
582        }
583        fn description(&self) -> &str {
584            "test"
585        }
586        fn parameters(&self) -> Value {
587            serde_json::json!({
588                "type":"object",
589                "required":["items","mode"],
590                "additionalProperties":false,
591                "properties":{
592                    "items":{"type":"array","items":{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}},
593                    "mode":{"enum":["safe","fast"]},
594                    "version":{"const":1},
595                    "choice":{"oneOf":[{"type":"string"},{"type":"number"}]}
596                }
597            })
598        }
599        fn output_schema(&self) -> Value {
600            serde_json::json!({"type":"object"})
601        }
602        async fn call(&self, args: Value) -> Result<Value, String> {
603            Ok(args)
604        }
605    }
606
607    #[tokio::test]
608    async fn registry_rejects_duplicates_and_validates_full_schema() {
609        let mut registry = ToolRegistry::new();
610        registry
611            .register(Arc::new(TestTool("nested.tool")))
612            .unwrap();
613        assert!(registry
614            .register(Arc::new(TestTool("nested.tool")))
615            .is_err());
616        let valid = serde_json::json!({"items":[{"id":1}],"mode":"safe","version":1,"choice":"x"});
617        assert_eq!(
618            registry
619                .execute_with_context("nested.tool", &execution(), valid.clone())
620                .await
621                .unwrap(),
622            valid
623        );
624        for invalid in [
625            serde_json::json!({"items":[{}],"mode":"safe"}),
626            serde_json::json!({"items":[{"id":1}],"mode":"unsafe"}),
627            serde_json::json!({"items":[{"id":1}],"mode":"safe","extra":true}),
628            serde_json::json!({"items":[{"id":1}],"mode":"safe","version":2}),
629            serde_json::json!({"items":[{"id":1}],"mode":"safe","choice":true}),
630        ] {
631            assert!(registry
632                .execute_with_context("nested.tool", &execution(), invalid)
633                .await
634                .is_err());
635        }
636    }
637
638    #[test]
639    fn model_names_are_provider_safe_and_reversible() {
640        let mut registry = ToolRegistry::new();
641        registry.register(Arc::new(TestTool("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"))).unwrap();
642        let spec = registry.specs().pop().unwrap().function.name;
643        assert!(spec.len() <= 64);
644        assert!(spec
645            .bytes()
646            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')));
647        assert_eq!(registry.canonical_name(&spec), Some("namespace.tool with unicode-工具-and-a-name-that-is-far-too-long-for-provider-contracts"));
648
649        let mut collision = ToolRegistry::new();
650        collision.register(Arc::new(TestTool("a.b"))).unwrap();
651        assert_eq!(model_tool_name("a.b"), "a_b_2e7336dc8eba87ef");
652        assert!(collision
653            .register(Arc::new(TestTool("a_b_2e7336dc8eba87ef")))
654            .is_err());
655    }
656
657    #[tokio::test]
658    async fn provider_junk_prefixes_resolve_once_at_the_registry_boundary() {
659        let mut registry = ToolRegistry::new();
660        registry
661            .register(Arc::new(TestTool("analyze_wallet")))
662            .unwrap();
663        let args = serde_json::json!({"items":[{"id":1}],"mode":"safe"});
664
665        assert_eq!(
666            registry
667                .execute_with_context("Notebookanalyze_wallet", &execution(), args.clone())
668                .await
669                .unwrap(),
670            args
671        );
672        assert!(registry
673            .execute_with_context("Listanalyze_wallets", &execution(), args.clone())
674            .await
675            .is_ok());
676        assert!(registry
677            .execute_with_context("namespace.analyze_wallet", &execution(), args)
678            .await
679            .is_err());
680    }
681
682    #[test]
683    fn invalid_schema_is_rejected_at_registration() {
684        struct Invalid;
685        #[async_trait]
686        impl Tool for Invalid {
687            fn name(&self) -> &str {
688                "invalid"
689            }
690            fn description(&self) -> &str {
691                "invalid"
692            }
693            fn parameters(&self) -> Value {
694                serde_json::json!({"type":"not-a-type"})
695            }
696            fn output_schema(&self) -> Value {
697                serde_json::json!({"type":"object"})
698            }
699            async fn call(&self, _: Value) -> Result<Value, String> {
700                Ok(Value::Null)
701            }
702        }
703        assert!(ToolRegistry::new().register(Arc::new(Invalid)).is_err());
704    }
705
706    #[tokio::test]
707    async fn successful_output_is_validated_before_materialization() {
708        struct InvalidOutput;
709        #[async_trait]
710        impl Tool for InvalidOutput {
711            fn name(&self) -> &str {
712                "invalid-output"
713            }
714            fn description(&self) -> &str {
715                "invalid output"
716            }
717            fn parameters(&self) -> Value {
718                serde_json::json!({"type":"object"})
719            }
720            fn output_schema(&self) -> Value {
721                serde_json::json!({"type":"object"})
722            }
723            async fn call(&self, _: Value) -> Result<Value, String> {
724                Ok(Value::String("bad".into()))
725            }
726        }
727        let mut registry = ToolRegistry::new();
728        registry.register(Arc::new(InvalidOutput)).unwrap();
729        assert!(registry
730            .execute_with_context("invalid-output", &execution(), serde_json::json!({}))
731            .await
732            .unwrap_err()
733            .contains("invalid tool output"));
734    }
735}