Skip to main content

rig_core/tool/
output.rs

1//! Canonical text, JSON, and multimodal tool output.
2//!
3//! ```
4//! use rig_core::tool::ToolOutput;
5//!
6//! let output = ToolOutput::text("done");
7//! assert_eq!(output.as_text(), Some("done"));
8//! ```
9
10use std::{any::Any, fmt};
11
12use serde::Serialize;
13
14use crate::{message::ToolResultContent, tool::ToolExecutionError};
15
16/// The canonical model-visible output produced by a tool.
17///
18/// Every output is stored as one or more typed [`ToolResultContent`] blocks.
19/// Ordinary serializable Rust values are converted through [`IntoToolOutput`]:
20/// values that serialize as JSON strings become literal text blocks and all
21/// other values become structured JSON blocks. An explicit
22/// [`serde_json::Value`], including a JSON string, stays JSON. Multimodal tools
23/// opt in explicitly with [`Self::content`]. Rig never reparses text as JSON to
24/// guess whether it represents rich content.
25#[derive(Clone, PartialEq)]
26pub struct ToolOutput {
27    content: Vec<ToolResultContent>,
28}
29
30// Serde is the content list itself; deserialization goes through
31// [`ToolOutput::content`] so an empty list is rejected at the boundary, the
32// same as at construction.
33impl Serialize for ToolOutput {
34    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
35        self.content.serialize(serializer)
36    }
37}
38
39impl<'de> serde::Deserialize<'de> for ToolOutput {
40    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
41        let content = Vec::<ToolResultContent>::deserialize(deserializer)?;
42        Self::content(content).map_err(serde::de::Error::custom)
43    }
44}
45
46impl fmt::Debug for ToolOutput {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        let content_kinds = self
49            .content
50            .iter()
51            .map(|content| match content {
52                ToolResultContent::Text(_) => "text",
53                ToolResultContent::Image(_) => "image",
54                ToolResultContent::Json { .. } => "json",
55            })
56            .collect::<Vec<_>>();
57        formatter
58            .debug_struct("ToolOutput")
59            .field("content_count", &self.content.len())
60            .field("content_kinds", &content_kinds)
61            .finish()
62    }
63}
64
65impl ToolOutput {
66    /// Construct literal text output.
67    pub fn text(text: impl Into<String>) -> Self {
68        Self::one(ToolResultContent::text(text))
69    }
70
71    /// Construct structured JSON output.
72    ///
73    /// Unlike an ordinary Rust string tool output, an explicit JSON string stays
74    /// a JSON content block.
75    pub fn json(value: serde_json::Value) -> Self {
76        Self::one(ToolResultContent::json(value))
77    }
78
79    /// Constructs explicit model content, rejecting an empty block list.
80    /// Use [`Self::text`] with `""` to represent an empty text result.
81    pub fn content(content: Vec<ToolResultContent>) -> Result<Self, ToolExecutionError> {
82        if content.is_empty() {
83            return Err(ToolExecutionError::other(
84                "tool output has no content blocks; return at least one block — \
85                 an empty text block is valid",
86            ));
87        }
88        Ok(Self { content })
89    }
90
91    /// Construct one explicit model-content block.
92    pub fn one(content: ToolResultContent) -> Self {
93        Self {
94            content: vec![content],
95        }
96    }
97
98    /// Return literal text when this output is exactly one plain text block.
99    pub fn as_text(&self) -> Option<&str> {
100        if self.content.len() != 1 {
101            return None;
102        }
103
104        match self.content.first()? {
105            ToolResultContent::Text(text) if text.native.is_none() => Some(&text.text),
106            ToolResultContent::Text(_)
107            | ToolResultContent::Image(_)
108            | ToolResultContent::Json { .. } => None,
109        }
110    }
111
112    /// Return structured JSON when this output is exactly one JSON block.
113    pub fn as_json(&self) -> Option<&serde_json::Value> {
114        if self.content.len() != 1 {
115            return None;
116        }
117
118        match self.content.first()? {
119            ToolResultContent::Json { value } => Some(value),
120            ToolResultContent::Text(_) | ToolResultContent::Image(_) => None,
121        }
122    }
123
124    /// Borrow the canonical ordered content blocks.
125    pub fn as_content(&self) -> &[ToolResultContent] {
126        &self.content
127    }
128
129    /// Convert this output into the canonical message content sent to a model.
130    pub fn into_content(self) -> Vec<ToolResultContent> {
131        self.content
132    }
133
134    /// Render a stable text representation for telemetry and diagnostics.
135    ///
136    /// This is a terminal rendering operation; the returned text is never used
137    /// to reconstruct structured output.
138    pub fn render(&self) -> String {
139        if let Some(text) = self.as_text() {
140            text.to_string()
141        } else if let Some(value) = self.as_json() {
142            value.to_string()
143        } else {
144            serde_json::to_string(&self.content)
145                .unwrap_or_else(|_| "<structured tool output>".to_string())
146        }
147    }
148}
149
150impl From<String> for ToolOutput {
151    fn from(text: String) -> Self {
152        Self::text(text)
153    }
154}
155
156impl From<&str> for ToolOutput {
157    fn from(text: &str) -> Self {
158        Self::text(text)
159    }
160}
161
162impl From<serde_json::Value> for ToolOutput {
163    fn from(value: serde_json::Value) -> Self {
164        Self::json(value)
165    }
166}
167
168impl From<ToolResultContent> for ToolOutput {
169    fn from(content: ToolResultContent) -> Self {
170        Self::one(content)
171    }
172}
173
174impl TryFrom<Vec<ToolResultContent>> for ToolOutput {
175    type Error = ToolExecutionError;
176
177    fn try_from(content: Vec<ToolResultContent>) -> Result<Self, Self::Error> {
178        Self::content(content)
179    }
180}
181
182/// Conversion into Rig's canonical tool output.
183///
184/// A blanket implementation keeps ordinary [`Serialize`] outputs ergonomic.
185/// Because that blanket implementation already covers every serializable type,
186/// it cannot be overridden with another implementation for a serializable
187/// custom type. Return [`ToolOutput`] from [`PortableTool::call`](crate::tool::PortableTool::call)
188/// when that type needs a custom presentation. Implement this trait directly
189/// only for output types that do not implement [`Serialize`].
190pub trait IntoToolOutput {
191    /// Convert this value without routing structured data through a string.
192    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError>;
193}
194
195#[cfg(test)]
196mod debug_tests;
197
198impl<T> IntoToolOutput for T
199where
200    T: Serialize + 'static,
201{
202    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError> {
203        // Preserve explicit content types before the serialization fallback so
204        // multimodal blocks are not converted into JSON objects.
205        let value = &self as &dyn Any;
206        if let Some(output) = value.downcast_ref::<ToolOutput>() {
207            return Ok(output.clone());
208        }
209        if let Some(content) = value.downcast_ref::<ToolResultContent>() {
210            return Ok(ToolOutput::one(content.clone()));
211        }
212        if let Some(content) = value.downcast_ref::<Vec<ToolResultContent>>() {
213            // Reject empty rich output as a tool failure rather than inventing
214            // text or allowing an invalid result into history.
215            return ToolOutput::content(content.clone());
216        }
217        let is_explicit_json = value.is::<serde_json::Value>();
218
219        serde_json::to_value(self)
220            .map(|value| match value {
221                serde_json::Value::String(text) if !is_explicit_json => ToolOutput::text(text),
222                value => ToolOutput::json(value),
223            })
224            .map_err(|error| {
225                ToolExecutionError::other(format!("failed to serialize tool output: {error}"))
226                    .with_source(error)
227            })
228    }
229}
230
231#[cfg(test)]
232mod tests;