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}