Skip to main content

ferrin_tool/
tool.rs

1//! The [`Tool`] type and its declarative parts.
2
3use std::fmt;
4use std::sync::Arc;
5
6use ferrin_schema::Schema;
7use ferrin_spec::BoxFuture;
8use ferrin_spec::JsonObject;
9use ferrin_spec::JsonValue;
10use ferrin_spec::ProviderOptions;
11use ferrin_spec::ToolCallId;
12use ferrin_spec::ToolDefinition;
13use ferrin_spec::ToolName;
14use ferrin_spec::error::TypeValidationContext;
15use ferrin_spec::error::TypeValidationError;
16use ferrin_spec::language_model::prompt::ToolResultOutput;
17
18use crate::callers::ToolCallerDefinition;
19use crate::execute::ToolContext;
20use crate::execute::ToolExecute;
21use crate::execute::ToolOutputStream;
22
23/// Who defines and who executes a tool.
24#[derive(Debug, Clone, PartialEq, Eq)]
25#[non_exhaustive]
26pub enum ToolKind {
27    /// Application-defined schema, executed by the application (with an
28    /// execute function) or by the caller (without one).
29    Function,
30    /// Defined at runtime (MCP and similar); input and output are untyped.
31    Dynamic,
32    /// Schema defined by the provider, executed by the application.
33    ProviderDefined {
34        /// Provider tool id in `<provider>.<tool>` form.
35        id: String,
36        /// Provider-specific configuration.
37        args: JsonObject,
38    },
39    /// Defined and executed by the provider.
40    ProviderExecuted {
41        /// Provider tool id in `<provider>.<tool>` form.
42        id: String,
43        /// Provider-specific configuration.
44        args: JsonObject,
45        /// The result may arrive in a later turn than the call.
46        supports_deferred_results: bool,
47    },
48}
49
50impl ToolKind {
51    /// Returns `true` for provider-defined and provider-executed tools.
52    #[must_use]
53    pub fn is_provider(&self) -> bool {
54        matches!(
55            self,
56            Self::ProviderDefined { .. } | Self::ProviderExecuted { .. }
57        )
58    }
59
60    /// Returns `true` when the provider executes the tool.
61    #[must_use]
62    pub fn is_provider_executed(&self) -> bool {
63        matches!(self, Self::ProviderExecuted { .. })
64    }
65
66    /// Returns `true` for dynamic tools.
67    #[must_use]
68    pub fn is_dynamic(&self) -> bool {
69        matches!(self, Self::Dynamic)
70    }
71
72    /// The provider tool id, for provider tools.
73    #[must_use]
74    pub fn provider_id(&self) -> Option<&str> {
75        match self {
76            Self::ProviderDefined { id, .. } | Self::ProviderExecuted { id, .. } => Some(id),
77            _ => None,
78        }
79    }
80}
81
82/// Inputs available when resolving a dynamic description.
83#[derive(Clone, Default)]
84pub struct DescriptionContext {
85    /// The validated tool context for this tool, if any.
86    pub tool_context: Option<JsonValue>,
87    /// The sandbox of the call, if any.
88    #[cfg(feature = "sandbox")]
89    pub sandbox: Option<Arc<dyn crate::sandbox::Sandbox>>,
90}
91
92impl DescriptionContext {
93    /// Creates a context carrying `tool_context`.
94    #[must_use]
95    pub fn with_tool_context(tool_context: JsonValue) -> Self {
96        Self {
97            tool_context: Some(tool_context),
98            #[cfg(feature = "sandbox")]
99            sandbox: None,
100        }
101    }
102}
103
104impl fmt::Debug for DescriptionContext {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        let mut debug = f.debug_struct("DescriptionContext");
107        debug.field("tool_context", &self.tool_context);
108        #[cfg(feature = "sandbox")]
109        debug.field(
110            "sandbox",
111            &self.sandbox.as_ref().map(|sandbox| sandbox.description()),
112        );
113        debug.finish()
114    }
115}
116
117/// Produces a description per call.
118pub type DescriptionFn =
119    Arc<dyn Fn(DescriptionContext) -> BoxFuture<'static, String> + Send + Sync>;
120
121/// Tool description sent to the model.
122#[derive(Clone)]
123#[non_exhaustive]
124pub enum Description {
125    /// A fixed string.
126    Static(String),
127    /// Computed from the call context.
128    Dynamic(DescriptionFn),
129}
130
131impl fmt::Debug for Description {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::Static(text) => f.debug_tuple("Static").field(text).finish(),
135            Self::Dynamic(_) => f.write_str("Dynamic(..)"),
136        }
137    }
138}
139
140impl Description {
141    /// Resolves the description.
142    pub async fn resolve(&self, ctx: DescriptionContext) -> String {
143        match self {
144            Self::Static(text) => text.clone(),
145            Self::Dynamic(function) => function(ctx).await,
146        }
147    }
148
149    /// The static text, if the description is static.
150    #[must_use]
151    pub fn as_static(&self) -> Option<&str> {
152        match self {
153            Self::Static(text) => Some(text),
154            Self::Dynamic(_) => None,
155        }
156    }
157}
158
159/// Decides per call whether approval is needed.
160pub type ApprovalFn = Arc<dyn Fn(JsonValue, ToolContext) -> BoxFuture<'static, bool> + Send + Sync>;
161
162/// Tool-level approval declaration. Call-level approval policies configured
163/// on the generation take precedence over it.
164#[derive(Clone, Default)]
165#[non_exhaustive]
166pub enum NeedsApproval {
167    /// Never ask.
168    #[default]
169    Never,
170    /// Always ask.
171    Always,
172    /// Ask when the function returns `true` for the validated input.
173    Dynamic(ApprovalFn),
174}
175
176impl fmt::Debug for NeedsApproval {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self {
179            Self::Never => f.write_str("Never"),
180            Self::Always => f.write_str("Always"),
181            Self::Dynamic(_) => f.write_str("Dynamic(..)"),
182        }
183    }
184}
185
186impl NeedsApproval {
187    /// Resolves the declaration for one call.
188    pub async fn resolve(&self, input: JsonValue, ctx: ToolContext) -> bool {
189        match self {
190            Self::Never => false,
191            Self::Always => true,
192            Self::Dynamic(function) => function(input, ctx).await,
193        }
194    }
195
196    /// Returns `true` unless the declaration is [`NeedsApproval::Never`].
197    #[must_use]
198    pub fn is_declared(&self) -> bool {
199        !matches!(self, Self::Never)
200    }
201}
202
203/// Called when the model starts producing input for the tool.
204pub type InputStartHook = Arc<dyn Fn(ToolContext) -> BoxFuture<'static, ()> + Send + Sync>;
205/// Called for each streamed input delta.
206pub type InputDeltaHook = Arc<dyn Fn(String, ToolContext) -> BoxFuture<'static, ()> + Send + Sync>;
207/// Called once the full input is available and valid.
208pub type InputAvailableHook =
209    Arc<dyn Fn(JsonValue, ToolContext) -> BoxFuture<'static, ()> + Send + Sync>;
210
211/// Lifecycle hooks around tool input.
212#[derive(Clone, Default)]
213pub struct ToolHooks {
214    /// See [`InputStartHook`].
215    pub on_input_start: Option<InputStartHook>,
216    /// See [`InputDeltaHook`].
217    pub on_input_delta: Option<InputDeltaHook>,
218    /// See [`InputAvailableHook`].
219    pub on_input_available: Option<InputAvailableHook>,
220}
221
222impl fmt::Debug for ToolHooks {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.debug_struct("ToolHooks")
225            .field("on_input_start", &self.on_input_start.is_some())
226            .field("on_input_delta", &self.on_input_delta.is_some())
227            .field("on_input_available", &self.on_input_available.is_some())
228            .finish()
229    }
230}
231
232/// Arguments of a [`ToModelOutputFn`].
233#[derive(Debug, Clone, Copy)]
234pub struct ModelOutputArgs<'a> {
235    /// The tool call id.
236    pub tool_call_id: &'a ToolCallId,
237    /// The validated input.
238    pub input: &'a JsonValue,
239    /// The execution output.
240    pub output: &'a JsonValue,
241}
242
243/// Converts an execution output into what the model receives.
244pub type ToModelOutputFn = Arc<dyn Fn(ModelOutputArgs<'_>) -> ToolResultOutput + Send + Sync>;
245
246/// A tool definition. Built with [`Tool::function`], [`Tool::dynamic`],
247/// [`Tool::provider_defined`] or [`Tool::provider_executed`].
248#[derive(Clone)]
249pub struct Tool {
250    pub(crate) kind: ToolKind,
251    pub(crate) description: Option<Description>,
252    pub(crate) title: Option<String>,
253    pub(crate) input_schema: Schema<JsonValue>,
254    pub(crate) output_schema: Option<Schema<JsonValue>>,
255    pub(crate) context_schema: Option<Schema<JsonValue>>,
256    pub(crate) execute: Option<Arc<dyn ToolExecute>>,
257    pub(crate) needs_approval: NeedsApproval,
258    pub(crate) strict: Option<bool>,
259    pub(crate) input_examples: Vec<JsonObject>,
260    pub(crate) metadata: Option<JsonObject>,
261    pub(crate) provider_options: Option<ProviderOptions>,
262    pub(crate) hooks: ToolHooks,
263    pub(crate) to_model_output: Option<ToModelOutputFn>,
264    pub(crate) caller_definition: Option<ToolCallerDefinition>,
265}
266
267impl fmt::Debug for Tool {
268    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
269        f.debug_struct("Tool")
270            .field("kind", &self.kind)
271            .field("description", &self.description)
272            .field("title", &self.title)
273            .field("input_schema", self.input_schema.json_schema())
274            .field("has_output_schema", &self.output_schema.is_some())
275            .field("has_context_schema", &self.context_schema.is_some())
276            .field("executable", &self.execute.is_some())
277            .field("needs_approval", &self.needs_approval)
278            .field("strict", &self.strict)
279            .field("input_examples", &self.input_examples)
280            .field("metadata", &self.metadata)
281            .field("provider_options", &self.provider_options)
282            .field("hooks", &self.hooks)
283            .field("has_to_model_output", &self.to_model_output.is_some())
284            .field("caller_definition", &self.caller_definition)
285            .finish()
286    }
287}
288
289impl Tool {
290    /// The kind.
291    #[must_use]
292    pub fn kind(&self) -> &ToolKind {
293        &self.kind
294    }
295
296    /// The description declaration.
297    #[must_use]
298    pub fn description(&self) -> Option<&Description> {
299        self.description.as_ref()
300    }
301
302    /// The title.
303    #[must_use]
304    pub fn title(&self) -> Option<&str> {
305        self.title.as_deref()
306    }
307
308    /// The input schema (erased to JSON).
309    #[must_use]
310    pub fn input_schema(&self) -> &Schema<JsonValue> {
311        &self.input_schema
312    }
313
314    /// The output schema, if declared.
315    #[must_use]
316    pub fn output_schema(&self) -> Option<&Schema<JsonValue>> {
317        self.output_schema.as_ref()
318    }
319
320    /// The context schema, if declared.
321    #[must_use]
322    pub fn context_schema(&self) -> Option<&Schema<JsonValue>> {
323        self.context_schema.as_ref()
324    }
325
326    /// The executor, if the tool is executed by the application.
327    #[must_use]
328    pub fn executor(&self) -> Option<&Arc<dyn ToolExecute>> {
329        self.execute.as_ref()
330    }
331
332    /// Returns `true` when the tool has an executor.
333    #[must_use]
334    pub fn is_executable(&self) -> bool {
335        self.execute.is_some()
336    }
337
338    /// The approval declaration.
339    #[must_use]
340    pub fn needs_approval(&self) -> &NeedsApproval {
341        &self.needs_approval
342    }
343
344    /// Strict-mode request.
345    #[must_use]
346    pub fn strict(&self) -> Option<bool> {
347        self.strict
348    }
349
350    /// Example inputs.
351    #[must_use]
352    pub fn input_examples(&self) -> &[JsonObject] {
353        &self.input_examples
354    }
355
356    /// Metadata propagated to tool calls (not sent to the model).
357    #[must_use]
358    pub fn metadata(&self) -> Option<&JsonObject> {
359        self.metadata.as_ref()
360    }
361
362    /// Provider options sent with the tool definition.
363    #[must_use]
364    pub fn provider_options(&self) -> Option<&ProviderOptions> {
365        self.provider_options.as_ref()
366    }
367
368    /// Input lifecycle hooks.
369    #[must_use]
370    pub fn hooks(&self) -> &ToolHooks {
371        &self.hooks
372    }
373
374    /// Custom model-output conversion.
375    #[must_use]
376    pub fn to_model_output(&self) -> Option<&ToModelOutputFn> {
377        self.to_model_output.as_ref()
378    }
379
380    /// Caller definition, when this tool can call other tools.
381    #[must_use]
382    pub fn caller_definition(&self) -> Option<&ToolCallerDefinition> {
383        self.caller_definition.as_ref()
384    }
385
386    /// Returns a copy with different provider options.
387    #[must_use]
388    pub fn with_provider_options(mut self, provider_options: Option<ProviderOptions>) -> Self {
389        self.provider_options = provider_options;
390        self
391    }
392
393    /// Resolves the description for a call (`None` when the tool has none).
394    pub async fn resolve_description(&self, ctx: DescriptionContext) -> Option<String> {
395        match &self.description {
396            Some(description) => Some(description.resolve(ctx).await),
397            None => None,
398        }
399    }
400
401    /// Builds the provider-facing definition under `name`.
402    #[must_use]
403    pub fn definition(&self, name: ToolName, description: Option<String>) -> ToolDefinition {
404        match &self.kind {
405            ToolKind::Function | ToolKind::Dynamic => ToolDefinition::Function {
406                name,
407                description,
408                input_schema: self.input_schema.json_schema().clone(),
409                strict: self.strict,
410                input_examples: self.input_examples.clone(),
411                provider_options: self.provider_options.clone(),
412            },
413            ToolKind::ProviderDefined { id, args }
414            | ToolKind::ProviderExecuted { id, args, .. } => ToolDefinition::Provider {
415                id: id.clone(),
416                name,
417                args: args.clone(),
418            },
419        }
420    }
421
422    /// Validates an input against the input schema.
423    ///
424    /// # Errors
425    ///
426    /// Returns the validation error with `field: "tool input"` and the tool
427    /// name as entity.
428    pub fn validate_input(
429        &self,
430        name: &ToolName,
431        input: JsonValue,
432    ) -> Result<JsonValue, TypeValidationError> {
433        self.input_schema.validate(input).map_err(|error| {
434            error.with_context(TypeValidationContext {
435                field: Some("tool input".to_owned()),
436                entity_name: Some(name.as_str().to_owned()),
437                entity_id: None,
438            })
439        })
440    }
441
442    /// Validates the selected tool context, preserving it when no schema is set.
443    ///
444    /// # Errors
445    ///
446    /// Returns the validation error with `field: "tool context"`.
447    pub fn validate_context(
448        &self,
449        name: &ToolName,
450        context: Option<JsonValue>,
451    ) -> Result<Option<JsonValue>, TypeValidationError> {
452        let Some(schema) = &self.context_schema else {
453            return Ok(context);
454        };
455        schema
456            .validate(context.unwrap_or(JsonValue::Null))
457            .map(Some)
458            .map_err(|error| {
459                error.with_context(TypeValidationContext {
460                    field: Some("tool context".to_owned()),
461                    entity_name: Some(name.as_str().to_owned()),
462                    entity_id: None,
463                })
464            })
465    }
466
467    /// Selects this tool's entry from a named context map and validates it.
468    ///
469    /// The map is keyed by the registered tool name. Missing entries remain
470    /// absent without a schema and are validated as JSON `null` with a schema.
471    ///
472    /// # Errors
473    ///
474    /// Returns a validation error for a non-object context map or when the
475    /// selected entry does not satisfy this tool's context schema.
476    pub fn validate_named_context(
477        &self,
478        name: &ToolName,
479        contexts: Option<&JsonValue>,
480    ) -> Result<Option<JsonValue>, TypeValidationError> {
481        if let Some(contexts) = contexts
482            && !contexts.is_object()
483            && !contexts.is_null()
484        {
485            return Err(TypeValidationError::new(
486                contexts.clone(),
487                std::io::Error::new(
488                    std::io::ErrorKind::InvalidInput,
489                    "tools context must be an object keyed by tool name",
490                ),
491            )
492            .with_context(TypeValidationContext {
493                field: Some("tools context".to_owned()),
494                entity_name: Some(name.as_str().to_owned()),
495                entity_id: None,
496            }));
497        }
498        self.validate_context(
499            name,
500            contexts.and_then(|value| value.get(name.as_str())).cloned(),
501        )
502    }
503
504    /// Starts an execution; `None` when the tool has no executor.
505    #[must_use]
506    pub fn execute(&self, input: JsonValue, ctx: ToolContext) -> Option<ToolOutputStream> {
507        self.execute
508            .as_ref()
509            .map(|executor| executor.execute(input, ctx))
510    }
511}