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            // `Some` params always carry data (`AdditionalParams` is
106            // non-empty by construction), so plain `is_none` is the whole
107            // annotation check.
108            ToolResultContent::Text(text) if text.additional_params.is_none() => Some(&text.text),
109            ToolResultContent::Text(_)
110            | ToolResultContent::Image(_)
111            | ToolResultContent::Json { .. } => None,
112        }
113    }
114
115    /// Return structured JSON when this output is exactly one JSON block.
116    pub fn as_json(&self) -> Option<&serde_json::Value> {
117        if self.content.len() != 1 {
118            return None;
119        }
120
121        match self.content.first()? {
122            ToolResultContent::Json { value } => Some(value),
123            ToolResultContent::Text(_) | ToolResultContent::Image(_) => None,
124        }
125    }
126
127    /// Borrow the canonical ordered content blocks.
128    pub fn as_content(&self) -> &[ToolResultContent] {
129        &self.content
130    }
131
132    /// Convert this output into the canonical message content sent to a model.
133    pub fn into_content(self) -> Vec<ToolResultContent> {
134        self.content
135    }
136
137    /// Render a stable text representation for telemetry and diagnostics.
138    ///
139    /// This is a terminal rendering operation; the returned text is never used
140    /// to reconstruct structured output.
141    pub fn render(&self) -> String {
142        if let Some(text) = self.as_text() {
143            text.to_string()
144        } else if let Some(value) = self.as_json() {
145            value.to_string()
146        } else {
147            serde_json::to_string(&self.content)
148                .unwrap_or_else(|_| "<structured tool output>".to_string())
149        }
150    }
151}
152
153impl From<String> for ToolOutput {
154    fn from(text: String) -> Self {
155        Self::text(text)
156    }
157}
158
159impl From<&str> for ToolOutput {
160    fn from(text: &str) -> Self {
161        Self::text(text)
162    }
163}
164
165impl From<serde_json::Value> for ToolOutput {
166    fn from(value: serde_json::Value) -> Self {
167        Self::json(value)
168    }
169}
170
171impl From<ToolResultContent> for ToolOutput {
172    fn from(content: ToolResultContent) -> Self {
173        Self::one(content)
174    }
175}
176
177impl TryFrom<Vec<ToolResultContent>> for ToolOutput {
178    type Error = ToolExecutionError;
179
180    fn try_from(content: Vec<ToolResultContent>) -> Result<Self, Self::Error> {
181        Self::content(content)
182    }
183}
184
185/// Conversion into Rig's canonical tool output.
186///
187/// A blanket implementation keeps ordinary [`Serialize`] outputs ergonomic.
188/// Because that blanket implementation already covers every serializable type,
189/// it cannot be overridden with another implementation for a serializable
190/// custom type. Return [`ToolOutput`] from [`PortableTool::call`](crate::tool::PortableTool::call)
191/// when that type needs a custom presentation. Implement this trait directly
192/// only for output types that do not implement [`Serialize`].
193pub trait IntoToolOutput {
194    /// Convert this value without routing structured data through a string.
195    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError>;
196}
197
198#[cfg(test)]
199mod debug_tests;
200
201impl<T> IntoToolOutput for T
202where
203    T: Serialize + 'static,
204{
205    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError> {
206        // Preserve explicit content types before the serialization fallback so
207        // multimodal blocks are not converted into JSON objects.
208        let value = &self as &dyn Any;
209        if let Some(output) = value.downcast_ref::<ToolOutput>() {
210            return Ok(output.clone());
211        }
212        if let Some(content) = value.downcast_ref::<ToolResultContent>() {
213            return Ok(ToolOutput::one(content.clone()));
214        }
215        if let Some(content) = value.downcast_ref::<Vec<ToolResultContent>>() {
216            // Reject empty rich output as a tool failure rather than inventing
217            // text or allowing an invalid result into history.
218            return ToolOutput::content(content.clone());
219        }
220        let is_explicit_json = value.is::<serde_json::Value>();
221
222        serde_json::to_value(self)
223            .map(|value| match value {
224                serde_json::Value::String(text) if !is_explicit_json => ToolOutput::text(text),
225                value => ToolOutput::json(value),
226            })
227            .map_err(|error| {
228                ToolExecutionError::other(format!("failed to serialize tool output: {error}"))
229                    .with_source(error)
230            })
231    }
232}
233
234#[cfg(test)]
235mod tests;