llm_tool/types.rs
1//! Core types for `llm-tool`: tool output, errors, context, and definitions.
2//!
3//! Defines the types that tools produce ([`ToolOutput`], [`ToolError`]),
4//! the execution context ([`ToolContext`]) shared across tool calls, and
5//! the [`ToolDefinition`] metadata sent to models.
6
7use alloc::{
8 borrow::ToOwned,
9 boxed::Box,
10 format,
11 string::{String, ToString},
12 sync::Arc,
13};
14use core::any::{Any, TypeId};
15
16use serde::{Deserialize, Serialize};
17
18use crate::compat::{HashMap, OnceLock, RwLock, read_lock, write_lock};
19
20/// A cheaply-cloneable handle to a tool's shared key-value state store.
21///
22/// [`ToolContext`] keeps its state behind this handle so multiple contexts
23/// (e.g. successive tool calls within the same agent turn) can read and write
24/// the **same** underlying store. Clone it and hand it to another context via
25/// [`ToolContext::with_shared_state`].
26///
27/// The concrete lock and map types are an implementation detail — they differ
28/// between `std` and `no_std` builds — so they are deliberately not exposed.
29#[derive(Clone)]
30pub struct SharedState(Arc<RwLock<HashMap<String, serde_json::Value>>>);
31
32impl SharedState {
33 /// Create a new, empty shared state store.
34 #[must_use]
35 pub fn new() -> Self {
36 Self::default()
37 }
38
39 /// Retrieve a value from the shared state, returning `default` if the key
40 /// is absent or the lock is poisoned.
41 #[must_use]
42 pub fn get_state(&self, key: &str, default: serde_json::Value) -> serde_json::Value {
43 match read_lock(&self.0) {
44 Ok(guard) => guard.get(key).cloned().unwrap_or(default),
45 Err(e) => {
46 tracing::warn!(key, error = %e, "SharedState::get_state: lock poisoned, returning default");
47 default
48 }
49 }
50 }
51
52 /// Insert or update a value in the shared state.
53 ///
54 /// # Errors
55 ///
56 /// Returns [`ToolError`] if the lock is poisoned.
57 pub fn set_state(&self, key: &str, value: serde_json::Value) -> Result<(), ToolError> {
58 match write_lock(&self.0) {
59 Ok(mut guard) => {
60 guard.insert(key.to_owned(), value);
61 Ok(())
62 }
63 Err(e) => {
64 let msg = format!("SharedState::set_state: lock poisoned for key '{key}': {e}");
65 tracing::warn!("{msg}");
66 Err(ToolError::new(msg))
67 }
68 }
69 }
70
71 /// Remove a value from the shared state by key, returning `true` if it was present.
72 #[must_use]
73 pub fn remove_state(&self, key: &str) -> bool {
74 match write_lock(&self.0) {
75 Ok(mut guard) => guard.remove(key).is_some(),
76 Err(e) => {
77 tracing::warn!(key, error = %e, "SharedState::remove_state: lock poisoned");
78 false
79 }
80 }
81 }
82
83 /// Clear all entries from the shared state.
84 pub fn clear_state(&self) {
85 match write_lock(&self.0) {
86 Ok(mut guard) => guard.clear(),
87 Err(e) => {
88 tracing::warn!(error = %e, "SharedState::clear_state: lock poisoned");
89 }
90 }
91 }
92}
93
94impl Default for SharedState {
95 fn default() -> Self {
96 Self(Arc::new(RwLock::new(HashMap::new())))
97 }
98}
99
100impl core::fmt::Debug for SharedState {
101 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
102 f.debug_struct("SharedState").finish_non_exhaustive()
103 }
104}
105
106/// Context passed to Rust tools during dispatch.
107///
108/// Provides access to the current conversation ID, a shared key-value state
109/// store ([`SharedState`]) that persists across tool calls within the same
110/// agent turn, and typed extensions via [`set_ext`](Self::set_ext) / [`get_ext`](Self::get_ext).
111pub struct ToolContext {
112 conversation_id: Option<String>,
113 state: SharedState,
114 extensions: Arc<RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>>,
115}
116
117impl Clone for ToolContext {
118 fn clone(&self) -> Self {
119 Self {
120 conversation_id: self.conversation_id.clone(),
121 state: self.state.clone(),
122 extensions: Arc::clone(&self.extensions),
123 }
124 }
125}
126
127impl core::fmt::Debug for ToolContext {
128 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
129 let count = match read_lock(&self.extensions) {
130 Ok(g) => g.len(),
131 Err(e) => {
132 tracing::warn!(error = %e, "ToolContext::fmt: extensions lock poisoned");
133 0
134 }
135 };
136 f.debug_struct("ToolContext")
137 .field("conversation_id", &self.conversation_id)
138 .field("state", &self.state)
139 .field("extensions_count", &count)
140 .finish()
141 }
142}
143
144impl Default for ToolContext {
145 fn default() -> Self {
146 Self::new()
147 }
148}
149
150impl ToolContext {
151 /// Create a new, empty context: no conversation ID and a fresh state store.
152 ///
153 /// Customize it with [`with_conversation_id`](Self::with_conversation_id)
154 /// and [`with_shared_state`](Self::with_shared_state).
155 #[must_use]
156 pub fn new() -> Self {
157 Self {
158 conversation_id: None,
159 state: SharedState::new(),
160 extensions: Arc::new(RwLock::new(HashMap::new())),
161 }
162 }
163
164 /// Set the conversation ID. Chainable.
165 #[must_use]
166 pub fn with_conversation_id(mut self, conversation_id: impl Into<String>) -> Self {
167 self.conversation_id = Some(conversation_id.into());
168 self
169 }
170
171 /// Derive a new context that carries a different conversation ID while
172 /// **sharing** this context's state store and typed extensions.
173 ///
174 /// Both the shared state ([`SharedState`]) and the typed extension map are
175 /// held behind `Arc`, so the returned context reads and writes the *same*
176 /// underlying stores — only the conversation identity differs. This is the
177 /// primitive a single MCP server uses to serve **many** callers: each
178 /// connection derives its own identity from the shared, session-wide
179 /// context without duplicating extensions like injected session state.
180 ///
181 /// ```rust
182 /// use std::sync::Arc;
183 ///
184 /// use llm_tool::ToolContext;
185 ///
186 /// let session = ToolContext::new().with_conversation_id("server");
187 /// session.set_ext(Arc::new(42u64)).unwrap();
188 ///
189 /// let alice = session.with_caller("alice");
190 /// assert_eq!(alice.conversation_id(), Some("alice"));
191 /// // Extensions are shared, not copied.
192 /// assert_eq!(alice.get_ext::<Arc<u64>>().as_deref(), Some(&42));
193 /// ```
194 #[must_use]
195 pub fn with_caller(&self, conversation_id: impl Into<String>) -> Self {
196 Self {
197 conversation_id: Some(conversation_id.into()),
198 state: self.state.clone(),
199 extensions: Arc::clone(&self.extensions),
200 }
201 }
202
203 /// Use an externally-provided [`SharedState`] as this context's state store.
204 ///
205 /// Use this when multiple `ToolContext` instances (e.g. successive tool
206 /// calls within the same agent) must read/write the **same** state store.
207 /// Obtain a handle from an existing context via
208 /// [`shared_state`](Self::shared_state).
209 #[must_use]
210 pub fn with_shared_state(mut self, state: SharedState) -> Self {
211 self.state = state;
212 self
213 }
214
215 /// Return a cloneable handle to this context's shared state store.
216 ///
217 /// Pass the returned handle to [`with_shared_state`](Self::with_shared_state)
218 /// on another context to share the same underlying store.
219 #[must_use]
220 pub fn shared_state(&self) -> SharedState {
221 self.state.clone()
222 }
223
224 /// Return the conversation ID, if one has been set.
225 #[must_use]
226 pub fn conversation_id(&self) -> Option<&str> {
227 self.conversation_id.as_deref()
228 }
229
230 /// Retrieve a value from the shared state, returning `default` if the key
231 /// is absent or the lock is poisoned.
232 ///
233 /// This method never fails — on a poisoned lock it logs a warning and
234 /// returns `default`. See the [struct-level docs](Self) for rationale.
235 #[must_use]
236 pub fn get_state(&self, key: &str, default: serde_json::Value) -> serde_json::Value {
237 match read_lock(&self.state.0) {
238 Ok(guard) => guard.get(key).cloned().unwrap_or(default),
239 Err(e) => {
240 tracing::warn!(key, error = %e, "ToolContext::get_state: lock poisoned, returning default");
241 default
242 }
243 }
244 }
245
246 /// Insert or update a value in the shared state.
247 ///
248 /// Unlike [`get_state`](Self::get_state), this method returns `Err` on a
249 /// poisoned lock because silently dropping a write can cause subtle bugs.
250 /// See the [struct-level docs](Self) for rationale.
251 ///
252 /// # Errors
253 ///
254 /// Returns [`ToolError`]
255 /// if the lock is poisoned.
256 pub fn set_state(&self, key: &str, value: serde_json::Value) -> Result<(), ToolError> {
257 match write_lock(&self.state.0) {
258 Ok(mut guard) => {
259 guard.insert(key.to_owned(), value);
260 Ok(())
261 }
262 Err(e) => {
263 let msg = format!("ToolContext::set_state: lock poisoned for key '{key}': {e}");
264 tracing::warn!("{msg}");
265 Err(ToolError::new(msg))
266 }
267 }
268 }
269
270 /// Store a typed value in the extensions map.
271 ///
272 /// Values are keyed by `TypeId`, so each concrete type can only appear
273 /// once. Typically used to store `Arc<T>` for shared, cloneable access.
274 ///
275 /// # Errors
276 ///
277 /// Returns [`ToolError`] if the lock is poisoned.
278 pub fn set_ext<T: Send + Sync + 'static>(&self, value: T) -> Result<(), ToolError> {
279 match write_lock(&self.extensions) {
280 Ok(mut exts) => {
281 exts.insert(TypeId::of::<T>(), Box::new(value));
282 Ok(())
283 }
284 Err(e) => {
285 let msg = format!(
286 "ToolContext::set_ext: lock poisoned for type '{}': {e}",
287 core::any::type_name::<T>()
288 );
289 tracing::warn!("{msg}");
290 Err(ToolError::new(msg))
291 }
292 }
293 }
294
295 /// Retrieve a clone of a typed value from the extensions map.
296 ///
297 /// Returns `None` if no value of type `T` has been stored via
298 /// [`set_ext`](Self::set_ext).
299 #[must_use]
300 pub fn get_ext<T: Clone + Send + Sync + 'static>(&self) -> Option<T> {
301 match read_lock(&self.extensions) {
302 Ok(exts) => exts
303 .get(&TypeId::of::<T>())
304 .and_then(|v| v.downcast_ref::<T>())
305 .cloned(),
306 Err(e) => {
307 tracing::warn!(error = %e, "ToolContext::get_ext: lock poisoned, returning None");
308 None
309 }
310 }
311 }
312}
313
314/// Re-export the `#[llm_tool]` proc macro for defining tools from plain functions.
315///
316/// # Usage
317///
318/// ```
319/// use llm_tool::{RustTool, ToolContext, ToolRegistry, llm_tool};
320///
321/// /// Adds two numbers together (with a twist).
322/// #[llm_tool]
323/// fn wonky_add(
324/// /// First number.
325/// a: i64,
326/// /// Second number.
327/// b: i64,
328/// ) -> Result<String, String> {
329/// Ok(format!("{}", a + b + 1))
330/// }
331///
332/// let mut registry = ToolRegistry::new();
333/// registry.register(WonkyAdd);
334/// assert_eq!(registry.definitions().len(), 1);
335/// ```
336pub use llm_tool_macros::{llm_prompt, llm_resource, llm_tool};
337// Re-export `JsonSchema` derive so tool authors can write `use llm_tool::JsonSchema;`
338// without adding `schemars` to their own `Cargo.toml`.
339pub use schemars::JsonSchema;
340
341/// Human-readable JSON type name for error messages.
342const fn other_type_name(value: &serde_json::Value) -> &'static str {
343 match value {
344 serde_json::Value::Null => "null",
345 serde_json::Value::Bool(_) => "bool",
346 serde_json::Value::Number(_) => "number",
347 serde_json::Value::String(_) => "string",
348 serde_json::Value::Array(_) => "array",
349 serde_json::Value::Object(_) => "object",
350 }
351}
352
353/// The return value of a Rust tool execution.
354///
355/// Every tool produces a `ToolOutput` containing:
356/// - **`content`**: the text sent back to the model.
357/// - **`metadata`**: an optional structured key-value map available to hooks,
358/// policies, and logging pipelines — but **never** sent to the model.
359///
360/// # Ergonomics
361///
362/// `ToolOutput` implements `From<String>`, `From<&str>`, and `Display`, so
363/// simple tools can return plain strings without ceremony:
364///
365/// ```rust
366/// use llm_tool::ToolOutput;
367/// use serde::Serialize;
368///
369/// // From a String
370/// let out: ToolOutput = "hello".to_string().into();
371/// assert_eq!(out.content(), "hello");
372/// assert!(out.metadata().is_empty());
373///
374/// // From &str
375/// let out: ToolOutput = "world".into();
376/// assert_eq!(out.content(), "world");
377///
378/// // Structured metadata from a typed struct (preferred)
379/// #[derive(Serialize)]
380/// struct ReadMeta {
381/// bytes_read: usize,
382/// cached: bool,
383/// }
384///
385/// let out = ToolOutput::new("file contents…")
386/// .with_metadata(&ReadMeta {
387/// bytes_read: 1024,
388/// cached: true,
389/// })
390/// .unwrap();
391/// assert_eq!(out.metadata()["bytes_read"], 1024);
392/// assert_eq!(out.metadata()["cached"], true);
393///
394/// // Single ad-hoc entry
395/// let out = ToolOutput::new("done").with_meta("exit_code", serde_json::json!(0));
396/// assert_eq!(out.metadata()["exit_code"], 0);
397/// ```
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399pub struct ToolOutput {
400 /// Text content sent to the LLM.
401 pub(crate) content: String,
402 /// Structured metadata attached to the output.
403 /// NOT sent to the LLM.
404 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
405 pub(crate) metadata: HashMap<String, serde_json::Value>,
406}
407
408impl ToolOutput {
409 /// Create a new `ToolOutput` with the given content and no metadata.
410 pub fn new(content: impl Into<String>) -> Self {
411 Self {
412 content: content.into(),
413 metadata: HashMap::new(),
414 }
415 }
416
417 /// Serialize a value to JSON and wrap it as tool output.
418 ///
419 /// The JSON string becomes the content sent to the model, but no
420 /// metadata is attached. For the zero-redundancy path that populates
421 /// **both** content and metadata from the same struct, use
422 /// [`from_metadata`](Self::from_metadata).
423 ///
424 /// ```rust
425 /// use llm_tool::{ToolOutput, ToolError};
426 ///
427 /// let data = serde_json::json!({"temp": 72, "unit": "F"});
428 /// let output = ToolOutput::json(&data).unwrap();
429 /// assert!(output.content().contains("72"));
430 /// assert!(output.metadata().is_empty()); // no metadata attached
431 /// ```
432 ///
433 /// # Errors
434 ///
435 /// Returns `Err(ToolError)` if serialization fails.
436 pub fn json<T: serde::Serialize>(value: &T) -> Result<Self, ToolError> {
437 serde_json::to_string(value)
438 .map(Self::new)
439 .map_err(|e| ToolError::new(format!("serialization failed: {e}")))
440 }
441
442 /// Create a `ToolOutput` where **both** the content and metadata come
443 /// from the same serializable value.
444 ///
445 /// - **Content** (sent to the model): the JSON representation of `value`.
446 /// - **Metadata** (hooks / policies / logging): the flattened object fields.
447 ///
448 /// This is the zero-redundancy path: define one struct, derive
449 /// `Serialize`, and everything is populated automatically.
450 ///
451 /// # Errors
452 ///
453 /// Returns `Err(ToolError)` if `value` doesn't serialize to a JSON object.
454 ///
455 /// # Example
456 ///
457 /// ```rust
458 /// use llm_tool::ToolOutput;
459 /// use serde::Serialize;
460 ///
461 /// #[derive(Serialize)]
462 /// struct Weather {
463 /// location: String,
464 /// temp_f: i32,
465 /// condition: String,
466 /// }
467 ///
468 /// let out = ToolOutput::from_metadata(&Weather {
469 /// location: "Seattle".into(),
470 /// temp_f: 72,
471 /// condition: "Sunny".into(),
472 /// })
473 /// .unwrap();
474 ///
475 /// // Model sees the JSON string
476 /// assert!(out.content().contains("Seattle"));
477 /// assert!(out.content().contains("72"));
478 ///
479 /// // Hooks see typed fields
480 /// assert_eq!(out.metadata()["location"], "Seattle");
481 /// assert_eq!(out.metadata()["temp_f"], 72);
482 /// ```
483 pub fn from_metadata<T: serde::Serialize>(value: &T) -> Result<Self, ToolError> {
484 let json_value = serde_json::to_value(value)
485 .map_err(|e| ToolError::new(format!("metadata serialization failed: {e}")))?;
486 // Serialize to string *before* destructuring so we borrow the Value
487 // instead of cloning the inner Map.
488 let content = json_value.to_string();
489 match json_value {
490 serde_json::Value::Object(map) => Ok(Self {
491 content,
492 metadata: map.into_iter().collect(),
493 }),
494 other => Err(ToolError::new(format!(
495 "metadata must serialize to a JSON object, got {}",
496 other_type_name(&other),
497 ))),
498 }
499 }
500
501 /// Attach a single metadata key-value pair. Chainable.
502 ///
503 /// For attaching multiple fields at once, prefer
504 /// [`with_metadata`](Self::with_metadata) with a typed struct.
505 #[must_use]
506 pub fn with_meta(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
507 self.metadata.insert(key.into(), value);
508 self
509 }
510
511 /// Attach structured metadata from a serializable value.
512 ///
513 /// The value is serialized to a JSON object and its fields are **merged**
514 /// into the metadata map. This is the preferred way to attach metadata
515 /// because it avoids stringly-typed keys and data duplication.
516 ///
517 /// # Errors
518 ///
519 /// Returns `Err(ToolError)` if `value` doesn't serialize to a JSON object
520 /// (e.g. it serializes to a scalar or array).
521 ///
522 /// # Example
523 ///
524 /// ```rust
525 /// use llm_tool::ToolOutput;
526 /// use serde::Serialize;
527 ///
528 /// #[derive(Serialize)]
529 /// struct FileMeta {
530 /// bytes_read: usize,
531 /// source: String,
532 /// }
533 ///
534 /// let out = ToolOutput::new("file contents")
535 /// .with_metadata(&FileMeta {
536 /// bytes_read: 1024,
537 /// source: "/etc/hosts".into(),
538 /// })
539 /// .unwrap();
540 /// assert_eq!(out.metadata()["bytes_read"], 1024);
541 /// assert_eq!(out.metadata()["source"], "/etc/hosts");
542 /// ```
543 pub fn with_metadata<T: serde::Serialize>(mut self, value: &T) -> Result<Self, ToolError> {
544 let json = serde_json::to_value(value)
545 .map_err(|e| ToolError::new(format!("metadata serialization failed: {e}")))?;
546 match json {
547 serde_json::Value::Object(map) => {
548 self.metadata.extend(map);
549 Ok(self)
550 }
551 other => Err(ToolError::new(format!(
552 "metadata must serialize to a JSON object, got {}",
553 other_type_name(&other),
554 ))),
555 }
556 }
557
558 /// The text content sent back to the model.
559 #[must_use]
560 pub fn content(&self) -> &str {
561 &self.content
562 }
563
564 /// Consume self and return the owned content string.
565 #[must_use]
566 pub fn into_content(self) -> String {
567 self.content
568 }
569
570 /// The structured metadata map.
571 #[must_use]
572 pub const fn metadata(&self) -> &HashMap<String, serde_json::Value> {
573 &self.metadata
574 }
575
576 /// Truncate the output text content according to the given [`TruncationPolicy`].
577 #[must_use]
578 pub fn truncated(mut self, policy: &TruncationPolicy) -> Self {
579 truncation::truncate_output(&mut self, policy);
580 self
581 }
582
583 /// Truncate the output text content according to the given [`TruncationPolicy`],
584 /// returning a detailed [`TruncationOutcome`].
585 ///
586 /// # Errors
587 ///
588 /// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
589 pub fn truncate_detailed(
590 &mut self,
591 policy: &TruncationPolicy,
592 ) -> Result<TruncationOutcome<'static>, SpillError> {
593 truncation::truncate_output_detailed(self, policy)
594 }
595
596 /// Truncate the output text content with caller context according to [`TruncationPolicy`].
597 ///
598 /// # Errors
599 ///
600 /// Returns [`SpillError`] if an [`OutputSpillSink`] is configured and fails.
601 pub fn truncate_detailed_with_context(
602 &mut self,
603 policy: &TruncationPolicy,
604 ctx: &SpillContext<'_>,
605 ) -> Result<TruncationOutcome<'static>, SpillError> {
606 truncation::truncate_output_detailed_with_context(self, policy, ctx)
607 }
608}
609
610impl core::fmt::Display for ToolOutput {
611 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
612 f.write_str(&self.content)
613 }
614}
615
616impl From<String> for ToolOutput {
617 fn from(content: String) -> Self {
618 Self::new(content)
619 }
620}
621
622impl From<&str> for ToolOutput {
623 fn from(content: &str) -> Self {
624 Self::new(content)
625 }
626}
627
628impl From<i64> for ToolOutput {
629 fn from(value: i64) -> Self {
630 Self::new(value.to_string())
631 }
632}
633
634impl From<f64> for ToolOutput {
635 fn from(value: f64) -> Self {
636 Self::new(value.to_string())
637 }
638}
639
640impl From<bool> for ToolOutput {
641 fn from(value: bool) -> Self {
642 Self::new(value.to_string())
643 }
644}
645
646impl From<serde_json::Value> for ToolOutput {
647 fn from(value: serde_json::Value) -> Self {
648 // serde_json::Value::to_string() never fails.
649 Self::new(value.to_string())
650 }
651}
652
653/// Wrapper for returning serializable values as JSON tool output.
654///
655/// Implements `From<Json<T>> for ToolOutput` so it works with the
656/// `#[llm_tool]` macro's `.into()` conversion — no `Result` wrapper needed
657/// for infallible serialization.
658///
659/// # Panics
660///
661/// The `From` conversion panics if `serde_json::to_string` fails.
662/// This only happens with broken `Serialize` implementations (e.g.,
663/// maps with non-string keys). For explicit error handling, use
664/// [`ToolOutput::json()`] instead.
665///
666/// # Example
667///
668/// ```rust
669/// use llm_tool::{Json, ToolOutput};
670/// use serde::Serialize;
671///
672/// #[derive(Serialize)]
673/// struct Weather {
674/// temp: f64,
675/// city: String,
676/// }
677///
678/// let output: ToolOutput = Json(Weather {
679/// temp: 72.0,
680/// city: "NYC".into(),
681/// })
682/// .into();
683/// assert!(output.content().contains("72"));
684/// ```
685pub struct Json<T>(pub T);
686
687impl<T: serde::Serialize> From<Json<T>> for ToolOutput {
688 fn from(json: Json<T>) -> Self {
689 let json_value = serde_json::to_value(&json.0)
690 .expect("Json<T> serialization failed — this is a bug in the Serialize impl");
691 let content = json_value.to_string();
692 match json_value {
693 serde_json::Value::Object(map) => Self {
694 content,
695 metadata: map.into_iter().collect(),
696 },
697 _ => Self::new(content),
698 }
699 }
700}
701
702/// The category of a registered entity (tool, prompt, or resource).
703///
704/// Used by [`ToolError::not_found`] and the registry dispatchers to
705/// provide clear, type-safe identification of which kind of item was
706/// looked up.
707///
708/// # Example
709///
710/// ```
711/// use llm_tool::RegistryItem;
712///
713/// assert_eq!(RegistryItem::Tool.to_string(), "tool");
714/// assert_eq!(RegistryItem::Prompt.to_string(), "prompt");
715/// assert_eq!(RegistryItem::Resource.to_string(), "resource");
716/// ```
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
718pub enum RegistryItem {
719 /// An executable tool.
720 Tool,
721 /// A prompt template.
722 Prompt,
723 /// A readable resource.
724 Resource,
725}
726
727impl core::fmt::Display for RegistryItem {
728 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
729 match self {
730 Self::Tool => write!(f, "tool"),
731 Self::Prompt => write!(f, "prompt"),
732 Self::Resource => write!(f, "resource"),
733 }
734 }
735}
736
737/// An error returned from a tool execution.
738///
739/// The error message is sent back to the model as the tool's error response.
740/// Structured metadata can be attached for hooks and logging — it is **not**
741/// sent to the model.
742///
743/// Implements `From<String>` and `From<&str>` for ergonomic construction.
744///
745/// # Example
746///
747/// ```
748/// use llm_tool::ToolError;
749/// use serde::Serialize;
750///
751/// let err: ToolError = "something went wrong".into();
752/// assert_eq!(err.to_string(), "something went wrong");
753///
754/// let err = ToolError::new(format!("failed to read {}", "file.txt"));
755/// assert!(err.to_string().contains("file.txt"));
756///
757/// // Structured metadata from a typed struct (preferred)
758/// #[derive(Serialize)]
759/// struct HttpErrorMeta {
760/// status_code: u16,
761/// url: String,
762/// }
763///
764/// let err = ToolError::new("HTTP request failed")
765/// .with_metadata(&HttpErrorMeta {
766/// status_code: 503,
767/// url: "https://example.com".into(),
768/// })
769/// .unwrap();
770/// assert_eq!(err.metadata()["status_code"], 503);
771///
772/// // Single ad-hoc entry
773/// let err = ToolError::new("timeout").with_meta("retry_after_secs", serde_json::json!(30));
774/// assert_eq!(err.metadata()["retry_after_secs"], 30);
775/// ```
776#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
777pub struct ToolError {
778 /// Human-readable error message sent to the model.
779 pub message: String,
780 /// Structured metadata for hooks / policies / logging.
781 /// NOT sent to the model.
782 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
783 metadata: HashMap<String, serde_json::Value>,
784}
785
786impl ToolError {
787 /// Create a new tool error with no metadata.
788 pub fn new(message: impl Into<String>) -> Self {
789 Self {
790 message: message.into(),
791 metadata: HashMap::new(),
792 }
793 }
794
795 /// Metadata key identifying the structured category of an error.
796 pub const ERROR_KIND_KEY: &'static str = "error_kind";
797 /// [`ERROR_KIND_KEY`](Self::ERROR_KIND_KEY) value used by
798 /// [`not_found`](Self::not_found) to mark a registry-lookup miss.
799 pub const KIND_NOT_REGISTERED: &'static str = "not_registered";
800 /// [`ERROR_KIND_KEY`](Self::ERROR_KIND_KEY) value used by
801 /// [`destructive_in_parallel_batch`](Self::destructive_in_parallel_batch) when a
802 /// destructive tool is rejected during parallel batch execution.
803 pub const KIND_DESTRUCTIVE_BATCH: &'static str = "destructive_in_parallel_batch";
804
805 /// Construct an error for a registry lookup that found no entry named
806 /// `name` of the given [`RegistryItem`].
807 ///
808 /// The human-readable message is suitable to hand back to the model so it
809 /// can self-correct (e.g. retry with a valid name). An
810 /// [`ERROR_KIND_KEY`](Self::ERROR_KIND_KEY) metadata field is attached —
811 /// never sent to the model — so hosts can still distinguish a routing miss
812 /// from a genuine execution failure for telemetry or policy decisions.
813 /// Prefer [`is_not_found`](Self::is_not_found) over inspecting the metadata
814 /// directly.
815 ///
816 /// # Example
817 ///
818 /// ```
819 /// use llm_tool::{RegistryItem, ToolError};
820 ///
821 /// let err = ToolError::not_found(RegistryItem::Tool, "add_nummbers");
822 /// assert!(err.to_string().contains("add_nummbers"));
823 /// assert!(err.is_not_found());
824 /// ```
825 #[must_use]
826 pub fn not_found(kind: RegistryItem, name: &str) -> Self {
827 Self::new(format!("no {kind} named '{name}' is registered")).with_meta(
828 Self::ERROR_KIND_KEY,
829 serde_json::json!(Self::KIND_NOT_REGISTERED),
830 )
831 }
832
833 /// Construct a typed error when a tool with [`ToolEffect::Destructive`] is rejected
834 /// in unconfirmed parallel batch dispatch.
835 #[must_use]
836 pub fn destructive_in_parallel_batch(tool_name: &str) -> Self {
837 Self::new(format!(
838 "Tool '{tool_name}' has Destructive effect and cannot be executed in Parallel batch mode without explicit confirmation"
839 ))
840 .with_meta(
841 Self::ERROR_KIND_KEY,
842 serde_json::json!(Self::KIND_DESTRUCTIVE_BATCH),
843 )
844 }
845
846 /// Attach a single metadata key-value pair. Chainable.
847 ///
848 /// For attaching multiple fields at once, prefer
849 /// [`with_metadata`](Self::with_metadata) with a typed struct.
850 #[must_use]
851 pub fn with_meta(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
852 self.metadata.insert(key.into(), value);
853 self
854 }
855
856 /// Attach structured metadata from a serializable value.
857 ///
858 /// The value is serialized to a JSON object and its fields are **merged**
859 /// into the metadata map. See [`ToolOutput::with_metadata`] for details.
860 ///
861 /// # Errors
862 ///
863 /// Returns `Err(self)` if `value` doesn't serialize to a JSON object.
864 pub fn with_metadata<T: serde::Serialize>(mut self, value: &T) -> Result<Self, Self> {
865 let json = match serde_json::to_value(value) {
866 Ok(j) => j,
867 Err(e) => {
868 self.message =
869 format!("{} (metadata serialization also failed: {e})", self.message);
870 return Err(self);
871 }
872 };
873 match json {
874 serde_json::Value::Object(map) => {
875 self.metadata.extend(map);
876 Ok(self)
877 }
878 other => {
879 self.message = format!(
880 "{} (metadata must serialize to a JSON object, got {})",
881 self.message,
882 other_type_name(&other),
883 );
884 Err(self)
885 }
886 }
887 }
888
889 /// The structured metadata map.
890 #[must_use]
891 pub const fn metadata(&self) -> &HashMap<String, serde_json::Value> {
892 &self.metadata
893 }
894
895 /// Whether this error denotes a registry-lookup miss, i.e. it was produced
896 /// by [`not_found`](Self::not_found).
897 ///
898 /// Lets hosts branch on "the model asked for something that isn't
899 /// registered" versus "a registered handler failed" without matching on
900 /// message strings.
901 ///
902 /// # Example
903 ///
904 /// ```
905 /// use llm_tool::{RegistryItem, ToolError};
906 ///
907 /// assert!(ToolError::not_found(RegistryItem::Prompt, "summarize").is_not_found());
908 /// assert!(!ToolError::new("handler blew up").is_not_found());
909 /// ```
910 #[must_use]
911 pub fn is_not_found(&self) -> bool {
912 self.metadata
913 .get(Self::ERROR_KIND_KEY)
914 .and_then(serde_json::Value::as_str)
915 == Some(Self::KIND_NOT_REGISTERED)
916 }
917
918 /// Whether this error denotes a [`ToolEffect::Destructive`] tool rejected
919 /// during parallel batch dispatch.
920 #[must_use]
921 pub fn is_destructive_batch_rejected(&self) -> bool {
922 self.metadata
923 .get(Self::ERROR_KIND_KEY)
924 .and_then(serde_json::Value::as_str)
925 == Some(Self::KIND_DESTRUCTIVE_BATCH)
926 }
927}
928
929impl core::fmt::Display for ToolError {
930 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
931 write!(f, "{}", self.message)
932 }
933}
934
935impl core::error::Error for ToolError {}
936
937impl From<String> for ToolError {
938 fn from(message: String) -> Self {
939 Self::new(message)
940 }
941}
942
943impl From<&str> for ToolError {
944 fn from(message: &str) -> Self {
945 Self::new(message)
946 }
947}
948
949#[cfg(feature = "std")]
950impl From<std::io::Error> for ToolError {
951 fn from(e: std::io::Error) -> Self {
952 Self::new(e.to_string()).with_meta(
953 Self::ERROR_KIND_KEY,
954 serde_json::json!(format!("{:?}", e.kind())),
955 )
956 }
957}
958
959impl From<serde_json::Error> for ToolError {
960 fn from(e: serde_json::Error) -> Self {
961 Self::new(e.to_string())
962 .with_meta("category", serde_json::json!(format!("{:?}", e.classify())))
963 }
964}
965
966impl From<Box<dyn core::error::Error + Send + Sync>> for ToolError {
967 fn from(e: Box<dyn core::error::Error + Send + Sync>) -> Self {
968 Self::new(e.to_string())
969 }
970}
971
972impl From<core::convert::Infallible> for ToolError {
973 fn from(never: core::convert::Infallible) -> Self {
974 match never {}
975 }
976}
977
978/// Effect classification for a tool.
979#[derive(
980 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema,
981)]
982#[serde(rename_all = "snake_case")]
983pub enum ToolEffect {
984 /// Read-only, side-effect free invocation. Safe for concurrent wave execution.
985 ReadOnly,
986 /// Mutates system state (default). Safe for normal execution.
987 #[default]
988 Mutating,
989 /// Destructive operation (e.g. deleting files, wiping data, dropping resources).
990 /// Requires explicit confirmation before parallel batch dispatch.
991 Destructive,
992}
993
994impl ToolEffect {
995 /// Returns `true` if the tool effect is read-only / idempotent.
996 #[must_use]
997 pub const fn is_read_only(self) -> bool {
998 matches!(self, Self::ReadOnly)
999 }
1000
1001 /// Returns `true` if the tool effect is destructive.
1002 #[must_use]
1003 pub const fn is_destructive(self) -> bool {
1004 matches!(self, Self::Destructive)
1005 }
1006}
1007
1008/// Describes a custom tool that can be registered with an agent.
1009#[non_exhaustive]
1010#[derive(Debug, Serialize)]
1011pub struct ToolDefinition {
1012 /// Unique tool name (e.g. `"flash_device"`).
1013 pub name: String,
1014 /// Human-readable description shown to the model.
1015 pub description: String,
1016 /// JSON Schema describing the tool's parameters.
1017 pub parameter_schema: serde_json::Value,
1018 /// Execution effect classification of the tool.
1019 pub effect: ToolEffect,
1020 /// Pre-serialized compact JSON Schema string cached lazily on first access.
1021 #[serde(skip)]
1022 parameter_schema_json: OnceLock<Arc<str>>,
1023}
1024
1025impl Clone for ToolDefinition {
1026 fn clone(&self) -> Self {
1027 Self {
1028 name: self.name.clone(),
1029 description: self.description.clone(),
1030 parameter_schema: self.parameter_schema.clone(),
1031 effect: self.effect,
1032 parameter_schema_json: OnceLock::new(),
1033 }
1034 }
1035}
1036
1037impl PartialEq for ToolDefinition {
1038 fn eq(&self, other: &Self) -> bool {
1039 self.name == other.name
1040 && self.description == other.description
1041 && self.parameter_schema == other.parameter_schema
1042 && self.effect == other.effect
1043 }
1044}
1045
1046impl Eq for ToolDefinition {}
1047
1048impl<'de> Deserialize<'de> for ToolDefinition {
1049 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1050 where
1051 D: serde::Deserializer<'de>,
1052 {
1053 #[derive(Deserialize)]
1054 struct RawDef {
1055 name: String,
1056 description: String,
1057 parameter_schema: serde_json::Value,
1058 #[serde(default)]
1059 effect: Option<ToolEffect>,
1060 #[serde(default)]
1061 idempotent: Option<bool>,
1062 }
1063
1064 let raw = RawDef::deserialize(deserializer)?;
1065 let effect = match (raw.effect, raw.idempotent) {
1066 (Some(eff), _) => eff,
1067 (None, Some(true)) => ToolEffect::ReadOnly,
1068 (None, Some(false) | None) => ToolEffect::Mutating,
1069 };
1070
1071 Ok(Self {
1072 name: raw.name,
1073 description: raw.description,
1074 parameter_schema: raw.parameter_schema,
1075 effect,
1076 parameter_schema_json: OnceLock::new(),
1077 })
1078 }
1079}
1080
1081impl ToolDefinition {
1082 /// Construct a [`ToolDefinition`] with default [`ToolEffect::Mutating`].
1083 #[must_use]
1084 pub fn new(
1085 name: impl Into<String>,
1086 description: impl Into<String>,
1087 parameter_schema: serde_json::Value,
1088 ) -> Self {
1089 Self::with_effect(name, description, parameter_schema, ToolEffect::Mutating)
1090 }
1091
1092 /// Construct a [`ToolDefinition`] with an explicit [`ToolEffect`].
1093 #[must_use]
1094 pub fn with_effect(
1095 name: impl Into<String>,
1096 description: impl Into<String>,
1097 parameter_schema: serde_json::Value,
1098 effect: ToolEffect,
1099 ) -> Self {
1100 Self {
1101 name: name.into(),
1102 description: description.into(),
1103 parameter_schema,
1104 effect,
1105 parameter_schema_json: OnceLock::new(),
1106 }
1107 }
1108
1109 /// Update `parameter_schema` and invalidate any cached JSON serialization.
1110 pub fn set_parameter_schema(&mut self, parameter_schema: serde_json::Value) {
1111 self.parameter_schema = parameter_schema;
1112 self.parameter_schema_json = OnceLock::new();
1113 }
1114
1115 /// Borrow `parameter_schema` mutably and invalidate any cached JSON serialization.
1116 pub fn parameter_schema_mut(&mut self) -> &mut serde_json::Value {
1117 self.parameter_schema_json = OnceLock::new();
1118 &mut self.parameter_schema
1119 }
1120
1121 /// Return the pre-serialized JSON schema string (`&str`), lazily derived
1122 /// from `parameter_schema`.
1123 #[must_use]
1124 pub fn parameter_schema_json(&self) -> &str {
1125 self.parameter_schema_json_arc()
1126 }
1127
1128 /// Return the pre-serialized JSON schema string (`&Arc<str>`), lazily derived
1129 /// from `parameter_schema`.
1130 #[must_use]
1131 pub fn parameter_schema_json_arc(&self) -> &Arc<str> {
1132 self.parameter_schema_json.get_or_init(|| {
1133 match serde_json::to_string(&self.parameter_schema) {
1134 Ok(s) => Arc::from(s),
1135 Err(err) => {
1136 tracing::warn!(error = %err, tool = %self.name, "Failed to serialize parameter_schema to JSON");
1137 Arc::from("{}")
1138 }
1139 }
1140 })
1141 }
1142
1143 /// Whether repeated invocations with identical arguments are side-effect free / idempotent.
1144 ///
1145 /// Preserved for backward compatibility.
1146 #[must_use]
1147 pub const fn is_idempotent(&self) -> bool {
1148 matches!(self.effect, ToolEffect::ReadOnly)
1149 }
1150}
1151
1152pub mod prompt;
1153pub mod quarantine;
1154pub mod resource;
1155pub mod truncation;
1156
1157pub use prompt::*;
1158pub use quarantine::*;
1159pub use resource::*;
1160pub use truncation::*;
1161
1162#[cfg(all(test, feature = "std"))]
1163mod tests;