Skip to main content

entelix_core/tools/
metadata.rs

1//! [`ToolMetadata`] — single source of truth for everything a tool
2//! advertises to the runtime, the model, and observability.
3//!
4//! `Tool` impls hold one of these as a field and return it from
5//! `Tool::metadata`. The struct is `#[non_exhaustive]` so future
6//! additions (effect taxonomy, retry knobs, scheduling hints) extend
7//! without touching call sites — operators always construct via
8//! [`ToolMetadata::function`] and the `with_*` chain.
9
10use std::time::Duration;
11
12use serde_json::Value;
13
14use crate::ir::ToolSpec;
15use crate::tools::effect::{RetryHint, ToolEffect};
16
17/// Declarative description of a tool.
18///
19/// Every field is plain-data; constructed once (typically in the
20/// tool's own `new()`) and returned by reference from
21/// `Tool::metadata`. The runtime treats this as authoritative —
22/// codecs render it into the on-the-wire `ToolSpec`, OTel layers
23/// stamp `gen_ai.tool.*` attributes from it, `Approver` defaults
24/// route off `effect`, and retry middleware honours `retry_hint`.
25#[derive(Clone, Debug)]
26#[non_exhaustive]
27pub struct ToolMetadata {
28    /// Stable identifier the model uses to call this tool. Must be
29    /// unique within a `ToolRegistry`. Conventionally `snake_case`.
30    pub name: String,
31    /// Human-readable description shown to the model. Used to help
32    /// the model decide when to call this tool — write it like a
33    /// function docstring.
34    pub description: String,
35    /// JSON Schema for the `input` payload that `Tool::execute`
36    /// accepts. Codecs translate this into the vendor's tool schema
37    /// format.
38    pub input_schema: Value,
39    /// Optional JSON Schema describing the *output* shape. Vendors
40    /// that support strict tool-output schemas (`OpenAI`'s
41    /// `strict: true`, Anthropic's response format hints) read
42    /// this. `None` = untyped JSON.
43    pub output_schema: Option<Value>,
44    /// Optional version string. Surfaces in OTel
45    /// (`gen_ai.tool.version`) and in audit events so operators can
46    /// distinguish between tool revisions when behaviour changes.
47    pub version: Option<String>,
48    /// Side-effect classification. Drives default `Approver`
49    /// behaviour (Destructive → require approval) and is rendered
50    /// to the LLM so the model can reason about safety on its own.
51    pub effect: ToolEffect,
52    /// `true` when calling the tool repeatedly with the same input
53    /// produces the same effect (no incremental change). Retry
54    /// middleware uses this as the cheap binary version of
55    /// `retry_hint.is_some()`.
56    pub idempotent: bool,
57    /// Per-tool retry policy hint. `None` (the default) means the
58    /// tool is *not* retried by middleware.
59    pub retry_hint: Option<RetryHint>,
60    /// Best-guess execution time for dashboards / scheduling. Used
61    /// only as a hint — the runtime never enforces it as a deadline
62    /// (use `ExecutionContext::deadline` for that).
63    pub typical_duration: Option<Duration>,
64}
65
66impl ToolMetadata {
67    /// Construct a function-tool descriptor with conservative
68    /// defaults (`effect = ReadOnly`, no retry, no version).
69    /// Customise via the `with_*` chain.
70    #[must_use]
71    pub fn function(
72        name: impl Into<String>,
73        description: impl Into<String>,
74        input_schema: Value,
75    ) -> Self {
76        Self {
77            name: name.into(),
78            description: description.into(),
79            input_schema,
80            output_schema: None,
81            version: None,
82            effect: ToolEffect::default(),
83            idempotent: false,
84            retry_hint: None,
85            typical_duration: None,
86        }
87    }
88
89    /// Attach an output schema.
90    #[must_use]
91    pub fn with_output_schema(mut self, schema: Value) -> Self {
92        self.output_schema = Some(schema);
93        self
94    }
95
96    /// Attach a version string.
97    #[must_use]
98    pub fn with_version(mut self, version: impl Into<String>) -> Self {
99        self.version = Some(version.into());
100        self
101    }
102
103    /// Override the side-effect classification.
104    #[must_use]
105    pub const fn with_effect(mut self, effect: ToolEffect) -> Self {
106        self.effect = effect;
107        self
108    }
109
110    /// Project this metadata into the wire-shaped [`ToolSpec`]
111    /// codecs encode for the model. Inspection helper used by
112    /// [`crate::tools::Toolset::tool_specs`] and capability manifests.
113    /// `cache_control` defaults to `None` — operators that need a
114    /// per-tool cache directive set it on the `ToolSpec` itself.
115    #[must_use]
116    pub fn to_tool_spec(&self) -> ToolSpec {
117        ToolSpec::function(
118            self.name.clone(),
119            self.description.clone(),
120            self.input_schema.clone(),
121        )
122    }
123
124    /// Mark the tool idempotent — repeat calls with the same input
125    /// produce the same effect.
126    #[must_use]
127    pub const fn with_idempotent(mut self, idempotent: bool) -> Self {
128        self.idempotent = idempotent;
129        self
130    }
131
132    /// Attach a retry hint. Implies `idempotent = true` because a
133    /// non-idempotent tool that opts into retries is a bug.
134    #[must_use]
135    pub const fn with_retry_hint(mut self, hint: RetryHint) -> Self {
136        self.retry_hint = Some(hint);
137        self.idempotent = true;
138        self
139    }
140
141    /// Attach a typical-duration hint.
142    #[must_use]
143    pub const fn with_typical_duration(mut self, duration: Duration) -> Self {
144        self.typical_duration = Some(duration);
145        self
146    }
147}
148
149#[cfg(test)]
150#[allow(clippy::unwrap_used)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    #[test]
156    fn function_defaults_are_conservative() {
157        let m = ToolMetadata::function("echo", "echoes input", json!({"type": "object"}));
158        assert_eq!(m.name, "echo");
159        assert_eq!(m.description, "echoes input");
160        assert!(m.output_schema.is_none());
161        assert!(m.version.is_none());
162        assert_eq!(m.effect, ToolEffect::ReadOnly);
163        assert!(!m.idempotent);
164        assert!(m.retry_hint.is_none());
165        assert!(m.typical_duration.is_none());
166    }
167
168    #[test]
169    fn with_retry_hint_implies_idempotent() {
170        let m = ToolMetadata::function("get", "fetches", json!({}))
171            .with_retry_hint(RetryHint::idempotent_transport());
172        assert!(m.idempotent);
173        assert!(m.retry_hint.is_some());
174    }
175
176    #[test]
177    fn builder_chain_is_const_friendly() {
178        let m = ToolMetadata::function("delete", "deletes a row", json!({}))
179            .with_effect(ToolEffect::Destructive)
180            .with_version("1.2.0")
181            .with_typical_duration(Duration::from_millis(50));
182        assert_eq!(m.effect, ToolEffect::Destructive);
183        assert_eq!(m.version.as_deref(), Some("1.2.0"));
184        assert_eq!(m.typical_duration, Some(Duration::from_millis(50)));
185    }
186}