Skip to main content

entelix_tools/
schema_tool.rs

1//! `SchemaTool` — typed-I/O ergonomics on top of [`entelix_core::Tool`].
2//!
3//! The base [`Tool`] trait takes `serde_json::Value` for both input
4//! and output so the dispatcher / registry / metadata machinery can
5//! be type-erased. Tool authors usually want the opposite: a typed
6//! `Input` they can pattern-match and a typed `Output` whose shape
7//! they fix at compile time. `SchemaTool` is the typed sibling — the
8//! adapter layer ([`SchemaToolAdapter`]) bridges back to the
9//! erased trait so the registry stays untouched.
10//!
11//! ## What you write
12//!
13//! ```no_run
14//! use entelix_core::AgentContext;
15//! use entelix_core::error::Result;
16//! use entelix_tools::{SchemaTool, SchemaToolExt};
17//! use schemars::JsonSchema;
18//! use serde::{Deserialize, Serialize};
19//!
20//! #[derive(Debug, Deserialize, JsonSchema)]
21//! pub struct DoubleInput {
22//!     pub n: i64,
23//! }
24//!
25//! #[derive(Debug, Serialize, JsonSchema)]
26//! pub struct DoubleOutput {
27//!     pub doubled: i64,
28//! }
29//!
30//! #[derive(Debug)]
31//! pub struct DoubleTool;
32//!
33//! #[async_trait::async_trait]
34//! impl SchemaTool for DoubleTool {
35//!     type Input = DoubleInput;
36//!     type Output = DoubleOutput;
37//!     const NAME: &'static str = "double";
38//!     fn description(&self) -> &str {
39//!         "Doubles an integer."
40//!     }
41//!     async fn execute(
42//!         &self,
43//!         input: Self::Input,
44//!         _ctx: &AgentContext<()>,
45//!     ) -> Result<Self::Output> {
46//!         Ok(DoubleOutput { doubled: input.n * 2 })
47//!     }
48//! }
49//!
50//! // Plug into any API that takes a `Tool`:
51//! let _tool = DoubleTool.into_adapter();
52//! ```
53//!
54//! ## What you get
55//!
56//! - **Compile-time I/O guarantees** — wrong-shape calls from inside
57//!   the agent runtime fail to type-check; deserialization failures
58//!   from the model surface as `Error::InvalidRequest`.
59//! - **Auto-generated input schema** — `schemars` walks `Input` and
60//!   produces the JSON Schema the codec advertises to the model. No
61//!   hand-rolled schema literals to drift out of sync with the
62//!   `Deserialize` impl.
63//! - **Effect / version metadata** — same `ToolEffect`, `RetryHint`,
64//!   and `version` knobs the erased `Tool` trait already exposes,
65//!   surfaced through provided trait methods so the typed author
66//!   never has to know about [`entelix_core::tools::ToolMetadata`].
67//!
68//! ## Invariant alignment
69//!
70//! - Invariant 4 (`Tool` is a leaf with one `execute` method):
71//!   `SchemaTool` is *not* `Tool`. The adapter `SchemaToolAdapter<T>`
72//!   is what implements `Tool`. Nothing on the dispatcher side needs
73//!   to know `SchemaTool` exists.
74//! - Invariant 10 (no tokens in tools): `SchemaTool::execute` takes
75//!   `&AgentContext`. Credentials still live in transports.
76//! - Cancellation (CLAUDE.md §"Cancellation"): typed authors check
77//!   `ctx.is_cancelled()` for long loops just like the erased path.
78
79use std::sync::Arc;
80
81use async_trait::async_trait;
82use entelix_core::AgentContext;
83use entelix_core::LlmFacingSchema;
84use entelix_core::error::{Error, Result};
85use entelix_core::tools::{RetryHint, Tool, ToolEffect, ToolMetadata};
86use schemars::JsonSchema;
87use serde::Serialize;
88use serde::de::DeserializeOwned;
89
90/// Typed-I/O sibling of [`Tool`]. Implementors get
91/// `Input`/`Output` typed against the model's tool dispatch
92/// without giving up the erased trait the rest of the SDK speaks.
93///
94/// Wrap with [`SchemaToolExt::into_adapter`] to expose the typed
95/// tool to a `ToolRegistry`.
96#[async_trait]
97pub trait SchemaTool: Send + Sync + 'static {
98    /// Typed input the model's tool call decodes into. `JsonSchema`
99    /// drives auto-schema generation; `DeserializeOwned + Send`
100    /// lets the adapter parse the model's `Value` payload without
101    /// borrowing.
102    type Input: DeserializeOwned + JsonSchema + Send + 'static;
103
104    /// Typed output the tool returns. The adapter re-serializes it
105    /// to `Value` for the codec; `Send` keeps the
106    /// adapter `async fn` `Send`.
107    type Output: Serialize + Send + 'static;
108
109    /// Stable tool name surfaced to the model. Must be unique
110    /// within a `ToolRegistry`. Conventionally `snake_case`.
111    const NAME: &'static str;
112
113    /// Tool description shown to the model. Implemented as a
114    /// method so authors can emit dynamic strings (e.g. include
115    /// the configured backend's name) — not a `const` because
116    /// `&'static str` would force every author into static storage.
117    fn description(&self) -> &str;
118
119    /// Side-effect classification. Defaults to
120    /// [`ToolEffect::ReadOnly`] — most tools don't mutate state.
121    /// Override when the tool writes / deletes / dispatches.
122    fn effect(&self) -> ToolEffect {
123        ToolEffect::default()
124    }
125
126    /// Optional retry hint surfaced through OTel. Defaults to
127    /// `None`; idempotent transports override.
128    fn retry_hint(&self) -> Option<RetryHint> {
129        None
130    }
131
132    /// Optional version label surfaced through OTel and audit
133    /// events. Useful when the same `NAME` ships behavioural
134    /// revisions.
135    fn version(&self) -> Option<&str> {
136        None
137    }
138
139    /// Optional output schema. Implementors override to enforce a
140    /// vendor strict-output contract — invoke
141    /// `schemars::schema_for!(Self::Output).to_value()` to mirror
142    /// the auto-generation the input side gets for free. The default
143    /// returns `None`.
144    fn output_schema(&self) -> Option<serde_json::Value> {
145        None
146    }
147
148    /// Whether the tool is idempotent — repeat calls with the same
149    /// input produce the same effect. Defaults to `false`; pure
150    /// computational tools (`ReadOnly` effect) and idempotent
151    /// transports override to `true` so the runtime can dedupe
152    /// retries server-side.
153    fn idempotent(&self) -> bool {
154        false
155    }
156
157    /// Run the tool against a typed input. The adapter handles
158    /// JSON deserialisation upstream — implementors only see fully
159    /// validated `Self::Input` and return a typed `Self::Output`.
160    /// Long loops should periodically check `ctx.is_cancelled()`.
161    async fn execute(&self, input: Self::Input, ctx: &AgentContext<()>) -> Result<Self::Output>;
162}
163
164/// Provided extension methods on every [`SchemaTool`]. Lives in a
165/// separate trait so blanket-impls (e.g. `Box<dyn SchemaTool>`)
166/// don't fight with the user-implemented `SchemaTool` trait
167/// associated types.
168pub trait SchemaToolExt: SchemaTool + Sized {
169    /// Wrap `self` in a [`SchemaToolAdapter`] so it can be
170    /// registered through any API that takes a `Tool`. The
171    /// adapter generates `Input`'s JSON schema once at
172    /// construction and caches it inside the metadata `Arc`.
173    fn into_adapter(self) -> SchemaToolAdapter<Self> {
174        SchemaToolAdapter::new(self)
175    }
176}
177
178impl<T: SchemaTool> SchemaToolExt for T {}
179
180/// Adapter that exposes any [`SchemaTool`] through the erased
181/// [`Tool`] trait.
182///
183/// The adapter owns the inner typed tool plus a pre-built
184/// [`ToolMetadata`] (input schema generated from the `Input` type,
185/// effect / version / retry hint pulled from the `SchemaTool`
186/// overrides) so the runtime hot path is a single pointer
187/// dereference.
188pub struct SchemaToolAdapter<T: SchemaTool> {
189    inner: T,
190    metadata: Arc<ToolMetadata>,
191}
192
193impl<T: SchemaTool> SchemaToolAdapter<T> {
194    /// Build the adapter, generating `T::Input`'s JSON schema once.
195    /// The schema is reduced through [`LlmFacingSchema::strip`] before
196    /// landing in [`ToolMetadata`] — the model never sees schemars
197    /// envelope keys (`$schema`, `title`, `$defs`, `$ref`, integer
198    /// width hints), saving 30–120 tokens per tool per turn
199    /// (invariant #16).
200    fn new(inner: T) -> Self {
201        let raw_schema: serde_json::Value = schemars::schema_for!(T::Input).to_value();
202        let input_schema = LlmFacingSchema::strip(&raw_schema);
203        let mut metadata = ToolMetadata::function(T::NAME, inner.description(), input_schema)
204            .with_effect(inner.effect())
205            .with_idempotent(inner.idempotent());
206        if let Some(version) = inner.version() {
207            metadata = metadata.with_version(version);
208        }
209        if let Some(hint) = inner.retry_hint() {
210            metadata = metadata.with_retry_hint(hint);
211        }
212        if let Some(output_schema) = inner.output_schema() {
213            metadata = metadata.with_output_schema(LlmFacingSchema::strip(&output_schema));
214        }
215        Self {
216            inner,
217            metadata: Arc::new(metadata),
218        }
219    }
220
221    /// Borrow the wrapped typed tool. Useful when registry-side
222    /// code wants to recover the typed handle for direct dispatch
223    /// (tests, alternative invokers).
224    pub const fn inner(&self) -> &T {
225        &self.inner
226    }
227}
228
229impl<T: SchemaTool> std::fmt::Debug for SchemaToolAdapter<T> {
230    /// Surfaces the metadata identity without forcing `T: Debug` —
231    /// the wrapped tool's internals are opaque to the adapter, so
232    /// transitively requiring a Debug bound on every typed tool is
233    /// noise. The metadata `name` is the operationally meaningful
234    /// identifier in logs / crash dumps.
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct("SchemaToolAdapter")
237            .field("name", &self.metadata.name)
238            .field("inner", &std::any::type_name::<T>())
239            .finish()
240    }
241}
242
243#[async_trait]
244impl<T: SchemaTool> Tool for SchemaToolAdapter<T> {
245    fn metadata(&self) -> &ToolMetadata {
246        &self.metadata
247    }
248
249    async fn execute(
250        &self,
251        input: serde_json::Value,
252        ctx: &AgentContext<()>,
253    ) -> Result<serde_json::Value> {
254        // Diagnostics surface only the tool name and the serde
255        // message — internal Rust type identifiers
256        // (`std::any::type_name`) are operator-only and would burn
257        // model attention without informing recovery (invariant #16).
258        let typed: T::Input = serde_json::from_value(input).map_err(|e| {
259            Error::invalid_request(format!(
260                "tool '{name}': input did not match schema: {e}",
261                name = T::NAME,
262            ))
263        })?;
264        let output = self.inner.execute(typed, ctx).await?;
265        serde_json::to_value(output).map_err(Error::from)
266    }
267}
268
269#[cfg(test)]
270#[allow(clippy::unwrap_used, clippy::expect_used)]
271mod tests {
272    use super::*;
273    use serde::Deserialize;
274    use serde_json::json;
275
276    #[derive(Debug, Deserialize, JsonSchema)]
277    struct DoubleInput {
278        n: i64,
279    }
280
281    #[derive(Debug, Serialize, JsonSchema)]
282    struct DoubleOutput {
283        doubled: i64,
284    }
285
286    #[derive(Debug)]
287    struct DoubleTool;
288
289    #[async_trait]
290    impl SchemaTool for DoubleTool {
291        type Input = DoubleInput;
292        type Output = DoubleOutput;
293        const NAME: &'static str = "double";
294
295        fn description(&self) -> &str {
296            "Doubles an integer."
297        }
298
299        async fn execute(
300            &self,
301            input: Self::Input,
302            _ctx: &AgentContext<()>,
303        ) -> Result<Self::Output> {
304            Ok(DoubleOutput {
305                doubled: input.n * 2,
306            })
307        }
308    }
309
310    #[derive(Debug)]
311    struct VersionedTool;
312
313    #[async_trait]
314    impl SchemaTool for VersionedTool {
315        type Input = DoubleInput;
316        type Output = DoubleOutput;
317        const NAME: &'static str = "versioned";
318
319        fn description(&self) -> &str {
320            "Versioned tool."
321        }
322
323        fn version(&self) -> Option<&str> {
324            Some("1.2.3")
325        }
326
327        fn effect(&self) -> ToolEffect {
328            ToolEffect::Mutating
329        }
330
331        async fn execute(
332            &self,
333            input: Self::Input,
334            _ctx: &AgentContext<()>,
335        ) -> Result<Self::Output> {
336            Ok(DoubleOutput {
337                doubled: input.n + 1,
338            })
339        }
340    }
341
342    #[derive(Debug)]
343    struct RetryableTool;
344
345    #[async_trait]
346    impl SchemaTool for RetryableTool {
347        type Input = DoubleInput;
348        type Output = DoubleOutput;
349        const NAME: &'static str = "retryable";
350
351        fn description(&self) -> &str {
352            "Retryable tool."
353        }
354
355        fn retry_hint(&self) -> Option<RetryHint> {
356            Some(RetryHint::idempotent_transport())
357        }
358
359        fn output_schema(&self) -> Option<serde_json::Value> {
360            Some(serde_json::json!({
361                "type": "object",
362                "properties": {
363                    "doubled": { "type": "integer" }
364                },
365                "required": ["doubled"]
366            }))
367        }
368
369        async fn execute(
370            &self,
371            input: Self::Input,
372            _ctx: &AgentContext<()>,
373        ) -> Result<Self::Output> {
374            Ok(DoubleOutput { doubled: input.n })
375        }
376    }
377
378    #[tokio::test]
379    async fn typed_round_trip_through_adapter() {
380        let adapter = DoubleTool.into_adapter();
381        let ctx = AgentContext::default();
382        let out = adapter.execute(json!({"n": 21}), &ctx).await.unwrap();
383        assert_eq!(out, json!({"doubled": 42}));
384    }
385
386    #[tokio::test]
387    async fn malformed_input_surfaces_invalid_request() {
388        let adapter = DoubleTool.into_adapter();
389        let ctx = AgentContext::default();
390        let err = adapter
391            .execute(json!({"wrong_field": 21}), &ctx)
392            .await
393            .unwrap_err();
394        let msg = err.to_string();
395        assert!(msg.contains("double"), "{msg}");
396        assert!(msg.contains("input did not match schema"), "{msg}");
397        // Invariant #16 — diagnostic must NOT leak internal Rust
398        // type identifiers (`DoubleInput`, module paths, …). The
399        // model gains nothing from `entelix_tools::schema_tool::tests::DoubleInput`.
400        assert!(
401            !msg.contains("DoubleInput"),
402            "internal type name must not surface to the model: {msg}"
403        );
404    }
405
406    #[test]
407    fn metadata_carries_autogenerated_input_schema() {
408        let adapter = DoubleTool.into_adapter();
409        let meta = adapter.metadata();
410        assert_eq!(meta.name, "double");
411        assert_eq!(meta.description, "Doubles an integer.");
412        // schemars emits a JSON Schema that mentions `n` as the
413        // expected property — exact shape varies with schemars
414        // versions, so we only assert it surfaced the field name.
415        let schema_str = meta.input_schema.to_string();
416        assert!(schema_str.contains("\"n\""), "{schema_str}");
417    }
418
419    #[test]
420    fn metadata_propagates_effect_and_version() {
421        let adapter = VersionedTool.into_adapter();
422        let meta = adapter.metadata();
423        assert_eq!(meta.effect, ToolEffect::Mutating);
424        assert_eq!(meta.version.as_deref(), Some("1.2.3"));
425    }
426
427    #[test]
428    fn defaults_apply_when_overrides_absent() {
429        let adapter = DoubleTool.into_adapter();
430        let meta = adapter.metadata();
431        assert_eq!(meta.effect, ToolEffect::ReadOnly);
432        assert!(meta.version.is_none());
433        assert!(meta.retry_hint.is_none());
434        assert!(meta.output_schema.is_none());
435    }
436
437    #[test]
438    fn metadata_propagates_retry_hint() {
439        let adapter = RetryableTool.into_adapter();
440        let meta = adapter.metadata();
441        assert!(meta.retry_hint.is_some());
442        // `with_retry_hint` flips `idempotent` to true (ToolMetadata
443        // contract) — verifying both keeps the wire-through honest.
444        assert!(meta.idempotent);
445    }
446
447    #[test]
448    fn metadata_propagates_output_schema() {
449        let adapter = RetryableTool.into_adapter();
450        let meta = adapter.metadata();
451        let schema = meta
452            .output_schema
453            .as_ref()
454            .expect("output_schema override should land in metadata");
455        let schema_str = schema.to_string();
456        assert!(schema_str.contains("doubled"), "{schema_str}");
457    }
458
459    #[derive(Debug, Default, PartialEq, Eq)]
460    struct StatefulTool {
461        marker: u32,
462    }
463
464    #[async_trait]
465    impl SchemaTool for StatefulTool {
466        type Input = DoubleInput;
467        type Output = DoubleOutput;
468        const NAME: &'static str = "stateful";
469
470        fn description(&self) -> &str {
471            "Stateful tool."
472        }
473
474        async fn execute(
475            &self,
476            input: Self::Input,
477            _ctx: &AgentContext<()>,
478        ) -> Result<Self::Output> {
479            Ok(DoubleOutput { doubled: input.n })
480        }
481    }
482
483    #[test]
484    fn inner_preserves_wrapped_instance_identity() {
485        // Sentinel value the wrapper must round-trip — guards against
486        // a regression where `inner()` returned a fresh `T::default()`
487        // or a different cell (the type-only check is too weak).
488        let adapter = StatefulTool {
489            marker: 0xDEAD_BEEF,
490        }
491        .into_adapter();
492        assert_eq!(adapter.inner().marker, 0xDEAD_BEEF);
493    }
494}