Skip to main content

nanocodex_oai_api/tools/
mod.rs

1//! Dependency-light contract for caller-defined and runtime-provided tools.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize, de::DeserializeOwned};
5use serde_json::{
6    Value,
7    value::{RawValue, to_raw_value},
8};
9
10pub use crate::responses::ToolDefinition;
11
12use crate::{ImageDetail, ResponseItem};
13
14/// Default maximum model-visible output budget for one tool call.
15pub const DEFAULT_TOOL_OUTPUT_TOKENS: usize = 10_000;
16
17/// Model-visible body returned by a tool.
18#[derive(Clone, Debug, Deserialize, Serialize)]
19#[serde(untagged)]
20pub enum ToolOutputBody {
21    /// Plain text, including serialized JSON returned by function tools.
22    Text(String),
23    /// Ordered multimodal output.
24    Content(Vec<ToolOutputContent>),
25}
26
27impl ToolOutputBody {
28    /// Returns the machine-readable value represented by this output.
29    #[must_use]
30    pub fn structured_result(&self) -> Value {
31        match self {
32            Self::Text(text) => Value::String(text.clone()),
33            Self::Content(content) => serde_json::to_value(content).unwrap_or(Value::Null),
34        }
35    }
36}
37
38/// One model-visible item in a multimodal tool output.
39#[derive(Clone, Debug, Deserialize, Serialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum ToolOutputContent {
42    /// Text input returned to the model.
43    InputText {
44        /// Complete text.
45        text: String,
46    },
47    /// Image input returned to the model.
48    InputImage {
49        /// Data URL or provider-supported image URL.
50        image_url: String,
51        /// Requested model image detail.
52        detail: ImageDetail,
53    },
54    /// Audio input returned to the model.
55    InputAudio {
56        /// Data URL or provider-supported audio URL.
57        audio_url: String,
58    },
59}
60
61/// Complete output of one tool invocation.
62///
63/// Use [`Self::text`], [`Self::json`], or [`Self::content`] for successful
64/// results. [`Self::error`] creates a structured model-visible failure without
65/// turning the handler invocation itself into an error.
66pub struct ToolOutput {
67    /// Model-visible output body.
68    pub output: ToolOutputBody,
69    /// Whether the remote or local operation succeeded.
70    pub success: bool,
71    /// Optional validated opaque metadata for events and adapters.
72    pub metadata: Option<Box<RawValue>>,
73    structured_result: Option<Value>,
74    process_trace: Option<ToolProcessTrace>,
75}
76
77/// Lossless process-boundary representation of a tool output.
78#[doc(hidden)]
79#[allow(missing_docs)]
80#[derive(Deserialize, Serialize)]
81pub struct ToolOutputWire {
82    pub output: ToolOutputBody,
83    pub success: bool,
84    pub structured_result: Option<Box<RawValue>>,
85    pub metadata: Option<Box<RawValue>>,
86    pub process_trace: Option<ToolProcessTraceWire>,
87}
88
89/// Process measurements attached by process-backed tool implementations.
90#[doc(hidden)]
91#[allow(missing_docs)]
92#[derive(Clone, Copy, Debug)]
93pub struct ToolProcessTrace {
94    pub exit_code: Option<i32>,
95    pub session_id: Option<i64>,
96    pub original_token_count: Option<usize>,
97    pub output_bytes: usize,
98    pub wall_time_seconds: f64,
99}
100
101/// Serialized process measurements.
102#[doc(hidden)]
103#[allow(missing_docs)]
104#[derive(Deserialize, Serialize)]
105pub struct ToolProcessTraceWire {
106    pub exit_code: Option<i32>,
107    pub session_id: Option<i64>,
108    pub original_token_count: Option<usize>,
109    pub output_bytes: usize,
110    pub wall_time_seconds: f64,
111}
112
113/// Error returned by an application-defined tool handler.
114pub type ToolError = Box<dyn std::error::Error + Send + Sync + 'static>;
115
116/// Result returned by [`Tool::execute`].
117///
118/// The owning runtime converts an error into a failed model-visible tool
119/// result so the model can recover. Return `Ok(ToolOutput::error(...))` only
120/// when preserving a structured failure from a remote tool protocol.
121pub type ToolResult = std::result::Result<ToolOutput, ToolError>;
122
123impl ToolOutput {
124    /// Creates a successful plain-text output.
125    #[must_use]
126    pub fn text(output: impl Into<String>) -> Self {
127        Self {
128            output: ToolOutputBody::Text(output.into()),
129            success: true,
130            metadata: None,
131            structured_result: None,
132            process_trace: None,
133        }
134    }
135
136    /// Creates a model-visible failed output.
137    #[must_use]
138    pub fn error(error: impl Into<String>) -> Self {
139        Self {
140            output: ToolOutputBody::Text(error.into()),
141            success: false,
142            metadata: None,
143            structured_result: None,
144            process_trace: None,
145        }
146    }
147
148    /// Serializes one successful function result as JSON text.
149    #[must_use]
150    pub fn json(output: &impl Serialize) -> Self {
151        match serde_json::to_value(output) {
152            Ok(output) => Self::from_json(output, true),
153            Err(error) => Self::error(format!("failed to encode tool result: {error}")),
154        }
155    }
156
157    /// Creates a JSON result with an explicit success state.
158    ///
159    /// Structured consumers receive the typed JSON value while the Responses
160    /// API receives its serialized text representation.
161    #[must_use]
162    pub fn from_json(output: Value, success: bool) -> Self {
163        match serde_json::to_string(&output) {
164            Ok(encoded) => Self {
165                output: ToolOutputBody::Text(encoded),
166                success,
167                metadata: None,
168                structured_result: Some(output),
169                process_trace: None,
170            },
171            Err(error) => Self::error(format!("failed to encode tool result: {error}")),
172        }
173    }
174
175    /// Creates a successful multimodal output.
176    #[must_use]
177    pub const fn content(output: Vec<ToolOutputContent>) -> Self {
178        Self {
179            output: ToolOutputBody::Content(output),
180            success: true,
181            metadata: None,
182            structured_result: None,
183            process_trace: None,
184        }
185    }
186
187    /// Attaches validated opaque metadata.
188    ///
189    /// An encoding failure converts this output into a model-visible failure.
190    #[must_use]
191    pub fn with_metadata(mut self, metadata: impl Serialize) -> Self {
192        match to_raw_value(&metadata) {
193            Ok(metadata) => self.metadata = Some(metadata),
194            Err(error) => {
195                self.output =
196                    ToolOutputBody::Text(format!("failed to encode tool result metadata: {error}"));
197                self.success = false;
198            }
199        }
200        self
201    }
202
203    /// Returns the exact machine-readable tool result.
204    ///
205    /// An explicit structured result takes precedence. Otherwise plain text
206    /// remains a string and multimodal content becomes an array.
207    #[must_use]
208    pub fn structured_result(&self) -> Value {
209        if let Some(value) = &self.structured_result {
210            return value.clone();
211        }
212        self.output.structured_result()
213    }
214
215    /// Sets the exact machine-readable result independently of model-visible output.
216    #[must_use]
217    pub fn with_structured_result(mut self, value: Value) -> Self {
218        self.structured_result = Some(value);
219        self
220    }
221
222    /// Attaches process measurements from a process-backed implementation.
223    #[doc(hidden)]
224    #[must_use]
225    pub const fn with_process_trace(
226        mut self,
227        exit_code: Option<i32>,
228        session_id: Option<i64>,
229        original_token_count: Option<usize>,
230        output_bytes: usize,
231        wall_time_seconds: f64,
232    ) -> Self {
233        self.process_trace = Some(ToolProcessTrace {
234            exit_code,
235            session_id,
236            original_token_count,
237            output_bytes,
238            wall_time_seconds,
239        });
240        self
241    }
242
243    /// Returns attached process measurements.
244    #[doc(hidden)]
245    #[must_use]
246    pub const fn process_trace(&self) -> Option<&ToolProcessTrace> {
247        self.process_trace.as_ref()
248    }
249
250    /// Converts this output into its lossless process-boundary form.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the internal structured result cannot be encoded.
255    #[doc(hidden)]
256    pub fn into_wire(self) -> Result<ToolOutputWire, serde_json::Error> {
257        Ok(ToolOutputWire {
258            output: self.output,
259            success: self.success,
260            structured_result: self
261                .structured_result
262                .map(|value| to_raw_value(&value))
263                .transpose()?,
264            metadata: self.metadata,
265            process_trace: self.process_trace.map(Into::into),
266        })
267    }
268
269    /// Restores an output received from a process boundary.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if an opaque structured result cannot be decoded.
274    #[doc(hidden)]
275    pub fn from_wire(wire: ToolOutputWire) -> Result<Self, serde_json::Error> {
276        Ok(Self {
277            output: wire.output,
278            success: wire.success,
279            metadata: wire.metadata,
280            structured_result: wire
281                .structured_result
282                .map(|value| serde_json::from_str(value.get()))
283                .transpose()?,
284            process_trace: wire.process_trace.map(Into::into),
285        })
286    }
287}
288
289impl From<ToolProcessTrace> for ToolProcessTraceWire {
290    fn from(trace: ToolProcessTrace) -> Self {
291        Self {
292            exit_code: trace.exit_code,
293            session_id: trace.session_id,
294            original_token_count: trace.original_token_count,
295            output_bytes: trace.output_bytes,
296            wall_time_seconds: trace.wall_time_seconds,
297        }
298    }
299}
300
301impl From<ToolProcessTraceWire> for ToolProcessTrace {
302    fn from(trace: ToolProcessTraceWire) -> Self {
303        Self {
304            exit_code: trace.exit_code,
305            session_id: trace.session_id,
306            original_token_count: trace.original_token_count,
307            output_bytes: trace.output_bytes,
308            wall_time_seconds: trace.wall_time_seconds,
309        }
310    }
311}
312
313/// Read-only context for one tool invocation.
314#[derive(Clone, Copy)]
315pub struct ToolContext<'a> {
316    model: &'a str,
317    session_id: &'a str,
318    call_id: &'a str,
319    history: &'a [ResponseItem],
320    output_token_budget: usize,
321}
322
323impl<'a> ToolContext<'a> {
324    /// Creates the complete read-only context for one tool invocation.
325    #[must_use]
326    pub const fn new(
327        model: &'a str,
328        session_id: &'a str,
329        call_id: &'a str,
330        history: &'a [ResponseItem],
331        output_token_budget: usize,
332    ) -> Self {
333        Self {
334            model,
335            session_id,
336            call_id,
337            history,
338            output_token_budget,
339        }
340    }
341
342    /// Returns the fixed model contract for this invocation.
343    #[must_use]
344    pub const fn model(self) -> &'a str {
345        self.model
346    }
347
348    /// Returns the stable client-owned session identity.
349    #[must_use]
350    pub const fn session_id(self) -> &'a str {
351        self.session_id
352    }
353
354    /// Returns the provider tool-call identity.
355    #[must_use]
356    pub const fn call_id(self) -> &'a str {
357        self.call_id
358    }
359
360    /// Returns committed authoritative history visible at this call boundary.
361    #[must_use]
362    pub const fn history(self) -> &'a [ResponseItem] {
363        self.history
364    }
365
366    /// Returns the maximum model-visible tool-output budget.
367    #[must_use]
368    pub const fn output_token_budget(self) -> usize {
369        self.output_token_budget
370    }
371}
372
373/// Canonical input presented to function and freeform tools.
374pub enum ToolInput {
375    /// Validated raw JSON arguments from a function call.
376    Function(Box<RawValue>),
377    /// Complete freeform custom-tool input.
378    Freeform(String),
379}
380
381impl ToolInput {
382    /// Borrows raw JSON function arguments without materializing a value tree.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error for freeform input.
387    pub fn function_json(&self) -> Result<&RawValue, ToolInputError> {
388        match self {
389            Self::Function(input) => Ok(input),
390            Self::Freeform(_) => Err(ToolInputError::ExpectedFunction),
391        }
392    }
393
394    /// Decodes JSON function arguments into a caller-selected type.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error for freeform input or invalid JSON arguments.
399    pub fn decode_json<T: DeserializeOwned>(&self) -> Result<T, ToolInputError> {
400        serde_json::from_str(self.function_json()?.get()).map_err(ToolInputError::Decode)
401    }
402
403    /// Extracts freeform source text.
404    ///
405    /// # Errors
406    ///
407    /// Returns an error for JSON function arguments.
408    pub fn into_freeform(self) -> Result<String, ToolInputError> {
409        match self {
410            Self::Freeform(input) => Ok(input),
411            Self::Function(_) => Err(ToolInputError::ExpectedFreeform),
412        }
413    }
414}
415
416/// Invalid access or decoding of typed tool input.
417#[derive(Debug, thiserror::Error)]
418pub enum ToolInputError {
419    /// A function tool received freeform input.
420    #[error("expected JSON function arguments")]
421    ExpectedFunction,
422    /// A custom tool received function arguments.
423    #[error("expected freeform tool input")]
424    ExpectedFreeform,
425    /// Function arguments did not decode into the requested type.
426    #[error("failed to parse function arguments: {0}")]
427    Decode(#[source] serde_json::Error),
428}
429
430/// A caller-defined model-visible tool.
431///
432/// ```
433/// use async_trait::async_trait;
434/// use nanocodex_oai_api::{
435///     responses::JsonSchema,
436///     tools::{
437///         Tool, ToolContext, ToolDefinition, ToolInput, ToolOutput, ToolResult,
438///     },
439/// };
440/// use serde_json::json;
441///
442/// struct DeploymentRegion;
443///
444/// #[async_trait]
445/// impl Tool for DeploymentRegion {
446///     fn definition(&self) -> ToolDefinition {
447///         ToolDefinition::function(
448///             "deployment_region",
449///             "Return the production deployment region.",
450///             JsonSchema::from(json!({
451///                 "type": "object",
452///                 "properties": {},
453///                 "additionalProperties": false
454///             })),
455///         )
456///     }
457///
458///     async fn execute(
459///         &self,
460///         input: ToolInput,
461///         _context: ToolContext<'_>,
462///     ) -> ToolResult {
463///         let _: serde_json::Value = input.decode_json()?;
464///         Ok(ToolOutput::text("us-west-2"))
465///     }
466/// }
467/// ```
468#[async_trait]
469pub trait Tool: Send + Sync + 'static {
470    /// Returns the complete model-visible definition and registry name.
471    fn definition(&self) -> ToolDefinition;
472
473    /// Returns whether this handler is safe to execute alongside sibling tool calls.
474    ///
475    /// Execution is serial by default. Opt in only when the handler's state and
476    /// effects are safe to overlap with other parallel-capable tools.
477    fn supports_parallel_tool_calls(&self) -> bool {
478        false
479    }
480
481    /// Executes one invocation.
482    async fn execute(&self, input: ToolInput, context: ToolContext<'_>) -> ToolResult;
483}
484
485#[cfg(test)]
486mod tests {
487    use serde_json::json;
488
489    use super::ToolOutput;
490
491    #[test]
492    fn structured_result_preserves_text_and_json_types() {
493        assert_eq!(ToolOutput::text("42").structured_result(), json!("42"));
494        assert_eq!(ToolOutput::json(&42).structured_result(), json!(42));
495    }
496}