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}