rig_core/tool/contextual.rs
1//! Contextual tool authoring and JSON-argument dispatch adapters.
2//! [`ToolContext`] carries typed inbound values and host-only result metadata;
3//! model-visible outputs retain their text, JSON, or multimodal representation.
4//!
5//! ```
6//! use rig_core::{message::ToolName, tool::{DynamicTool, ToolOutput}};
7//!
8//! let tool = DynamicTool::new_with_context(ToolName::new("echo")?, "Echo JSON", serde_json::json!({}),
9//! |_context, args| Box::pin(async move { Ok(ToolOutput::json(args)) }));
10//! assert_eq!(tool.name(), "echo");
11//! # Ok::<(), rig_core::message::EmptyToolName>(())
12//! ```
13
14use std::{future::Future, sync::Arc};
15
16use serde::{Deserialize, Serialize};
17
18use crate::{
19 completion::{ToolAnnotations, ToolDefinition, message::ToolName},
20 serve::{ErasedHandler, adapters::ToolCallback},
21 wasm_compat::{WasmBoxedFuture, WasmCompatSend, WasmCompatSync},
22};
23
24use super::{IntoToolOutput, ToolContext, ToolExecutionError, ToolOutput, ToolResult, Touches};
25
26/// A typed LLM tool.
27///
28/// Tool authors provide metadata and exactly one execution method. Runtime
29/// context and host-only result metadata share the [`ToolContext`] path. Rig's
30/// object-safe dispatch boundary is private; use [`DynamicTool`] when the tool
31/// name or callback is only known at runtime.
32pub trait Tool: Sized + WasmCompatSend + WasmCompatSync {
33 /// Unique registration and provider-facing name.
34 const NAME: &'static str;
35 /// Typed JSON arguments.
36 type Args: for<'de> Deserialize<'de> + WasmCompatSend + WasmCompatSync;
37 /// Output convertible into Rig's canonical model presentation.
38 ///
39 /// Every owned serializable value implements [`IntoToolOutput`]
40 /// automatically. [`ToolResultContent`](crate::message::ToolResultContent)
41 /// and `Vec<ToolResultContent>` preserve rich content when returned
42 /// directly; use [`ToolOutput`] when constructing the presentation
43 /// explicitly.
44 type Output: IntoToolOutput;
45 /// Typed error returned by direct calls to this tool.
46 ///
47 /// Rig normalizes this error into [`ToolExecutionError`] only at the erased
48 /// dispatch boundary. This keeps ordinary `?` propagation and typed unit
49 /// tests available to tool authors without creating a second runtime error
50 /// representation.
51 type Error: std::error::Error + WasmCompatSend + WasmCompatSync + 'static;
52
53 /// Model-facing description.
54 fn description(&self) -> String;
55
56 /// JSON Schema for arguments.
57 fn parameters(&self) -> serde_json::Value;
58
59 /// What calling the tool does to the world, for the host's policies
60 /// (approval, plan mode, which calls run side by side). Nothing is
61 /// stated by default, which reads as the protocol's defaults
62 /// ([`ToolAnnotations`]).
63 fn annotations(&self) -> ToolAnnotations {
64 ToolAnnotations::default()
65 }
66
67 /// What a call with `args` may read and change, so a host runs the
68 /// calls of one reply that touch different things side by side. The
69 /// default reads it from the [annotations](Self::annotations): a
70 /// read-only tool reads anything, any other writes anything.
71 fn footprint(&self, args: &Self::Args) -> Touches {
72 let _ = args;
73 Touches::from_annotations(&self.annotations())
74 }
75
76 /// Normalize a typed author-facing error for runtime policy and telemetry.
77 ///
78 /// The default preserves concrete sources for operators and exposes safe
79 /// kind-level model feedback. An existing [`ToolExecutionError`] retains its
80 /// classification and model output. Override to supply deliberate domain feedback.
81 fn map_error(&self, error: Self::Error) -> ToolExecutionError {
82 ToolExecutionError::from_error(error)
83 }
84
85 /// Execute the tool.
86 fn call(
87 &self,
88 context: &mut ToolContext,
89 args: Self::Args,
90 ) -> impl Future<Output = Result<Self::Output, Self::Error>> + WasmCompatSend;
91}
92
93/// A tool that can be stored in a vector store and reconstructed for RAG.
94pub trait ToolEmbedding: Tool {
95 /// Error returned while reconstructing the tool.
96 type InitError: std::error::Error + WasmCompatSend + WasmCompatSync + 'static;
97 /// Serializable static context.
98 type Context: for<'de> Deserialize<'de> + Serialize;
99 /// Runtime initialization state.
100 type State: WasmCompatSend;
101
102 /// Documents used to retrieve the tool.
103 fn embedding_docs(&self) -> Vec<String>;
104 /// Serializable tool context.
105 fn context(&self) -> Self::Context;
106 /// Reconstruct the tool.
107 fn init(state: Self::State, context: Self::Context) -> Result<Self, Self::InitError>;
108}
109
110pub(crate) fn parse_tool_args<A>(args: &str) -> Result<A, ToolExecutionError>
111where
112 A: serde::de::DeserializeOwned,
113{
114 match serde_json::from_str(args) {
115 Ok(parsed) => Ok(parsed),
116 Err(original) if args.trim() == "null" => serde_json::from_str("{}").map_err(|_| {
117 ToolExecutionError::invalid_args(format!("failed to parse tool arguments: {original}"))
118 .with_source(original)
119 }),
120 Err(error) => Err(ToolExecutionError::invalid_args(format!(
121 "failed to parse tool arguments: {error}"
122 ))
123 .with_source(error)),
124 }
125}
126
127pub(crate) fn tool_result_from<O>(outcome: Result<O, ToolExecutionError>) -> ToolResult
128where
129 O: IntoToolOutput,
130{
131 match outcome.and_then(IntoToolOutput::into_tool_output) {
132 Ok(output) => ToolResult::success(output),
133 Err(error) => ToolResult::failed(error),
134 }
135}
136
137/// Reports whether a tool's owner still serves it. Registries check lazily on
138/// reads or reconciliation; calls before retirement may fail with a transport
139/// error rather than `HandlerUnavailable`.
140#[cfg(not(target_family = "wasm"))]
141pub type LivenessFn = Arc<dyn Fn() -> bool + Send + Sync>;
142/// A liveness probe (browser wasm: no `Send + Sync`, no threads).
143#[cfg(target_family = "wasm")]
144pub type LivenessFn = Arc<dyn Fn() -> bool>;
145
146/// A tool defined at runtime: its definition and a callback over JSON
147/// arguments. It is its own handler ([`Serve`](crate::serve::Serve)), keyed
148/// by its name. The optional liveness probe supports registry retirement;
149/// serving does not consult it.
150#[derive(Clone)]
151pub struct DynamicTool {
152 definition: ToolDefinition,
153 callback: Arc<dyn ToolCallback>,
154 liveness: Option<LivenessFn>,
155}
156
157impl DynamicTool {
158 /// Define a tool from a context-free callback over owned arguments.
159 pub fn new<F>(
160 name: ToolName,
161 description: impl Into<String>,
162 parameters: serde_json::Value,
163 callback: F,
164 ) -> Self
165 where
166 F: Fn(
167 serde_json::Value,
168 ) -> WasmBoxedFuture<'static, Result<ToolOutput, ToolExecutionError>>
169 + WasmCompatSend
170 + WasmCompatSync
171 + 'static,
172 {
173 Self::new_with_context(
174 name,
175 description,
176 parameters,
177 move |_context: &mut ToolContext, arguments| callback(arguments),
178 )
179 }
180
181 /// Define a tool from a callback over the dispatch-scoped context.
182 pub fn new_with_context<F>(
183 name: ToolName,
184 description: impl Into<String>,
185 parameters: serde_json::Value,
186 callback: F,
187 ) -> Self
188 where
189 F: ToolCallback + 'static,
190 {
191 Self {
192 definition: ToolDefinition::new(name, description, parameters),
193 callback: Arc::new(callback),
194 liveness: None,
195 }
196 }
197
198 /// The same tool, stating what calling it does to the world.
199 pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
200 self.definition.annotations = annotations;
201 self
202 }
203
204 /// Attach a liveness probe.
205 pub fn with_liveness<F>(mut self, is_live: F) -> Self
206 where
207 F: Fn() -> bool + WasmCompatSend + WasmCompatSync + 'static,
208 {
209 self.liveness = Some(Arc::new(is_live));
210 self
211 }
212
213 /// The tool's name.
214 pub fn name(&self) -> &ToolName {
215 &self.definition.name
216 }
217
218 /// The tool's definition.
219 pub fn definition(&self) -> ToolDefinition {
220 self.definition.clone()
221 }
222
223 /// Consumes the tool into its definition, its erased handler, and its
224 /// optional liveness probe.
225 pub fn into_parts(self) -> (ToolDefinition, ErasedHandler, Option<LivenessFn>) {
226 let Self {
227 definition,
228 callback,
229 liveness,
230 } = self;
231 let handler = ErasedHandler::new(Self {
232 definition: definition.clone(),
233 callback,
234 liveness: None,
235 });
236 (definition, handler, liveness)
237 }
238
239 /// Runs the tool on JSON `args` with an empty [`ToolContext`], as
240 /// [`Self::run_with`] does.
241 ///
242 /// ```
243 /// use rig_core::{message::ToolName, tool::{DynamicTool, ToolExecutionError, ToolOutput}};
244 /// use serde_json::json;
245 ///
246 /// let add = DynamicTool::new(ToolName::new("add")?, "Adds a and b", json!({}), |args| {
247 /// Box::pin(async move {
248 /// let int = |key| args.get(key).and_then(serde_json::Value::as_i64);
249 /// let sum = int("a").zip(int("b")).map(|(a, b)| a + b);
250 /// sum.map(|sum| ToolOutput::json(json!(sum)))
251 /// .ok_or_else(|| ToolExecutionError::invalid_args("a and b must be integers"))
252 /// })
253 /// });
254 /// let output = futures::executor::block_on(add.run(json!({ "a": 2, "b": 3 })))?;
255 /// assert_eq!(output.as_json(), Some(&json!(5)));
256 /// assert!(futures::executor::block_on(add.run(json!({ "a": "two" }))).is_err());
257 /// # Ok::<(), Box<dyn std::error::Error>>(())
258 /// ```
259 pub async fn run(&self, args: serde_json::Value) -> Result<ToolOutput, ToolExecutionError> {
260 self.run_with(&mut ToolContext::new(), args).await
261 }
262
263 /// Runs the tool on JSON `args` as a dispatch of its call runs it, and
264 /// returns its output or its error (invalid arguments included). The
265 /// tool sees `context`'s inbound values, and what it publishes with
266 /// `insert_result` lands in `context`. No registry or agent is involved.
267 ///
268 /// ```
269 /// use rig_core::{message::ToolName, tool::{ContextValue, DynamicTool, ToolContext, ToolOutput}};
270 /// use serde_json::json;
271 ///
272 /// #[derive(serde::Serialize, serde::Deserialize)]
273 /// struct User(String);
274 /// impl ContextValue for User { const KEY: &'static str = "user"; }
275 /// #[derive(serde::Serialize, serde::Deserialize)]
276 /// struct Greeted(bool);
277 /// impl ContextValue for Greeted { const KEY: &'static str = "greeted"; }
278 ///
279 /// let greet = DynamicTool::new_with_context(ToolName::new("greet")?, "Greets the user", json!({}),
280 /// |context, _args| Box::pin(async move {
281 /// let user = context.require::<User>()?;
282 /// context.insert_result(Greeted(true))?;
283 /// Ok(ToolOutput::text(format!("hello {}", user.0)))
284 /// }));
285 /// let mut context = ToolContext::new();
286 /// context.insert(User("ada".into()))?;
287 /// let output = futures::executor::block_on(greet.run_with(&mut context, json!({})))?;
288 /// assert_eq!(output.as_text(), Some("hello ada"));
289 /// assert_eq!(context.result::<Greeted>()?.map(|greeted| greeted.0), Some(true));
290 /// # Ok::<(), Box<dyn std::error::Error>>(())
291 /// ```
292 pub async fn run_with(
293 &self,
294 context: &mut ToolContext,
295 args: serde_json::Value,
296 ) -> Result<ToolOutput, ToolExecutionError> {
297 crate::serve::adapters::run_direct(context, args, async |context, args| {
298 (self.callback)(context, args).await
299 })
300 .await
301 }
302
303 /// Whether the tool's owner still serves it (`true` without a probe).
304 pub fn is_live(&self) -> bool {
305 self.liveness.as_ref().is_none_or(|probe| probe())
306 }
307}
308
309impl crate::serve::Serve for DynamicTool {
310 type Family = crate::effect::family::Tool;
311
312 fn descriptor(&self) -> crate::effect::HandlerDescriptor {
313 let ToolDefinition {
314 name,
315 description,
316 parameters,
317 ..
318 } = &self.definition;
319 crate::effect::HandlerDescriptor::tool(name, description, parameters.clone())
320 }
321
322 fn key(&self) -> crate::effect::HandlerKey {
323 crate::effect::tool_key(&self.definition.name)
324 }
325
326 async fn serve(
327 &self,
328 kind: crate::effect::EffectKind,
329 dispatch: crate::serve::Dispatch,
330 ) -> crate::serve::Reply {
331 crate::serve::adapters::call_tool(kind, dispatch, None, async |context, args| {
332 (self.callback)(context, args).await
333 })
334 .await
335 }
336}
337
338impl std::fmt::Debug for DynamicTool {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 f.debug_struct("DynamicTool")
341 .field("name", &self.definition.name)
342 .finish_non_exhaustive()
343 }
344}
345
346/// A typed tool's [`Tool::NAME`] as a [`ToolName`].
347///
348/// An empty `NAME` fails the build where this is instantiated.
349///
350/// ```
351/// use rig_core::{message::ToolChoice, tool::{Tool, ToolContext, tool_name}};
352///
353/// struct Ping;
354///
355/// impl Tool for Ping {
356/// const NAME: &'static str = "ping";
357/// type Args = serde_json::Value;
358/// type Output = String;
359/// type Error = std::convert::Infallible;
360///
361/// fn description(&self) -> String {
362/// "Replies with pong.".to_string()
363/// }
364///
365/// fn parameters(&self) -> serde_json::Value {
366/// serde_json::json!({ "type": "object" })
367/// }
368///
369/// async fn call(
370/// &self,
371/// _context: &mut ToolContext,
372/// _args: Self::Args,
373/// ) -> Result<String, Self::Error> {
374/// Ok("pong".to_string())
375/// }
376/// }
377///
378/// let choice = ToolChoice::Specific { function_names: vec![tool_name::<Ping>()] };
379/// assert_eq!(tool_name::<Ping>(), "ping");
380/// # let _ = choice;
381/// ```
382pub fn tool_name<T: Tool>() -> ToolName {
383 const { assert!(!T::NAME.is_empty(), "Tool::NAME cannot be empty") };
384 ToolName::new_unchecked(T::NAME)
385}
386
387/// A tool's [`ToolDefinition`] from a typed tool, with its annotations.
388pub fn tool_definition<T: Tool>(tool: &T) -> ToolDefinition {
389 ToolDefinition::new(tool_name::<T>(), tool.description(), tool.parameters())
390 .with_annotations(tool.annotations())
391}