Skip to main content

rig_core/tool/
output.rs

1//! Canonical model-visible tool output.
2
3use std::{any::Any, fmt};
4
5use serde::Serialize;
6
7use crate::{OneOrMany, message::ToolResultContent, tool::ToolExecutionError};
8
9/// The canonical model-visible output produced by a tool.
10///
11/// Every output is stored as one or more typed [`ToolResultContent`] blocks.
12/// Ordinary serializable Rust values are converted through [`IntoToolOutput`]:
13/// values that serialize as JSON strings become literal text blocks and all
14/// other values become structured JSON blocks. An explicit
15/// [`serde_json::Value`], including a JSON string, stays JSON. Multimodal tools
16/// opt in explicitly with [`Self::content`]. Rig never reparses text as JSON to
17/// guess whether it represents rich content.
18#[derive(Clone, PartialEq)]
19pub struct ToolOutput {
20    content: OneOrMany<ToolResultContent>,
21}
22
23impl fmt::Debug for ToolOutput {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        let content_kinds = self
26            .content
27            .iter()
28            .map(|content| match content {
29                ToolResultContent::Text(_) => "text",
30                ToolResultContent::Image(_) => "image",
31                ToolResultContent::Json { .. } => "json",
32            })
33            .collect::<Vec<_>>();
34        formatter
35            .debug_struct("ToolOutput")
36            .field("content_count", &self.content.len())
37            .field("content_kinds", &content_kinds)
38            .finish()
39    }
40}
41
42impl ToolOutput {
43    /// Construct literal text output.
44    pub fn text(text: impl Into<String>) -> Self {
45        Self::one(ToolResultContent::text(text))
46    }
47
48    /// Construct structured JSON output.
49    ///
50    /// Unlike an ordinary Rust string tool output, an explicit JSON string stays
51    /// a JSON content block.
52    pub fn json(value: serde_json::Value) -> Self {
53        Self::one(ToolResultContent::json(value))
54    }
55
56    /// Construct explicit model content.
57    pub fn content(content: OneOrMany<ToolResultContent>) -> Self {
58        Self { content }
59    }
60
61    /// Construct one explicit model-content block.
62    pub fn one(content: ToolResultContent) -> Self {
63        Self::content(OneOrMany::one(content))
64    }
65
66    /// Return literal text when this output is exactly one plain text block.
67    pub fn as_text(&self) -> Option<&str> {
68        if self.content.len() != 1 {
69            return None;
70        }
71
72        match self.content.first_ref() {
73            ToolResultContent::Text(text) if text.additional_params.is_none() => Some(&text.text),
74            ToolResultContent::Text(_)
75            | ToolResultContent::Image(_)
76            | ToolResultContent::Json { .. } => None,
77        }
78    }
79
80    /// Return structured JSON when this output is exactly one JSON block.
81    pub fn as_json(&self) -> Option<&serde_json::Value> {
82        if self.content.len() != 1 {
83            return None;
84        }
85
86        match self.content.first_ref() {
87            ToolResultContent::Json { value } => Some(value),
88            ToolResultContent::Text(_) | ToolResultContent::Image(_) => None,
89        }
90    }
91
92    /// Borrow the canonical ordered content blocks.
93    pub fn as_content(&self) -> &OneOrMany<ToolResultContent> {
94        &self.content
95    }
96
97    /// Convert this output into the canonical message content sent to a model.
98    pub fn into_content(self) -> OneOrMany<ToolResultContent> {
99        self.content
100    }
101
102    /// Render a stable text representation for telemetry and diagnostics.
103    ///
104    /// This is a terminal rendering operation; the returned text is never used
105    /// to reconstruct structured output.
106    pub fn render(&self) -> String {
107        if let Some(text) = self.as_text() {
108            text.to_string()
109        } else if let Some(value) = self.as_json() {
110            value.to_string()
111        } else {
112            serde_json::to_string(&self.content)
113                .unwrap_or_else(|_| "<structured tool output>".to_string())
114        }
115    }
116}
117
118impl From<String> for ToolOutput {
119    fn from(text: String) -> Self {
120        Self::text(text)
121    }
122}
123
124impl From<&str> for ToolOutput {
125    fn from(text: &str) -> Self {
126        Self::text(text)
127    }
128}
129
130impl From<serde_json::Value> for ToolOutput {
131    fn from(value: serde_json::Value) -> Self {
132        Self::json(value)
133    }
134}
135
136impl From<ToolResultContent> for ToolOutput {
137    fn from(content: ToolResultContent) -> Self {
138        Self::one(content)
139    }
140}
141
142impl From<OneOrMany<ToolResultContent>> for ToolOutput {
143    fn from(content: OneOrMany<ToolResultContent>) -> Self {
144        Self::content(content)
145    }
146}
147
148/// Conversion into Rig's canonical tool output.
149///
150/// A blanket implementation keeps ordinary [`Serialize`] outputs ergonomic.
151/// Because that blanket implementation already covers every serializable type,
152/// it cannot be overridden with another implementation for a serializable
153/// custom type. Return [`ToolOutput`] from [`PortableTool::call`](crate::tool::PortableTool::call)
154/// when that type needs a custom presentation. Implement this trait directly
155/// only for output types that do not implement [`Serialize`].
156pub trait IntoToolOutput {
157    /// Convert this value without routing structured data through a string.
158    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError>;
159}
160
161#[cfg(test)]
162mod debug_tests {
163    use crate::message::ImageMediaType;
164
165    use super::*;
166
167    #[test]
168    fn debug_reports_shape_without_tool_content() {
169        let output = ToolOutput::content(
170            OneOrMany::many(vec![
171                ToolResultContent::text("Bearer secret-tool-output"),
172                ToolResultContent::json(serde_json::json!({
173                    "credential": "secret-json-output"
174                })),
175                ToolResultContent::image_base64(
176                    "secret-image-output",
177                    Some(ImageMediaType::PNG),
178                    None,
179                ),
180            ])
181            .unwrap(),
182        );
183
184        let debug = format!("{output:?}");
185        assert!(debug.contains("content_count: 3"));
186        assert!(debug.contains("text"));
187        assert!(debug.contains("json"));
188        assert!(debug.contains("image"));
189        for secret in [
190            "secret-tool-output",
191            "secret-json-output",
192            "secret-image-output",
193        ] {
194            assert!(!debug.contains(secret));
195        }
196    }
197}
198
199impl<T> IntoToolOutput for T
200where
201    T: Serialize + 'static,
202{
203    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError> {
204        // `ToolResultContent` and `OneOrMany<ToolResultContent>` are serializable
205        // because they also serve as transcript types. They nevertheless mean
206        // explicit rich output here; serializing them through the fallback would
207        // silently turn an image into a JSON object. Stable Rust cannot express
208        // a blanket `Serialize` impl with negative exceptions, so preserve these
209        // two canonical rich types before taking the serialization path.
210        let value = &self as &dyn Any;
211        if let Some(content) = value.downcast_ref::<ToolResultContent>() {
212            return Ok(ToolOutput::one(content.clone()));
213        }
214        if let Some(content) = value.downcast_ref::<OneOrMany<ToolResultContent>>() {
215            return Ok(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
231impl IntoToolOutput for ToolOutput {
232    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError> {
233        Ok(self)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use crate::message::{DocumentSourceKind, ImageMediaType};
240
241    use super::*;
242
243    #[test]
244    fn json_shaped_strings_remain_literal_text() {
245        let text = r#"{"type":"image","data":"not-an-envelope"}"#.to_string();
246        let output = text.clone().into_tool_output().unwrap();
247
248        assert_eq!(output, ToolOutput::text(text.clone()));
249        let content = output.into_content();
250        assert!(matches!(content.first(), ToolResultContent::Text(value) if value.text == text));
251    }
252
253    #[test]
254    fn structured_values_remain_json_until_terminal_rendering() {
255        let value = serde_json::json!({"status": "ok", "count": 2});
256        let output = value.clone().into_tool_output().unwrap();
257
258        assert_eq!(output, ToolOutput::json(value.clone()));
259        assert_eq!(output.render(), value.to_string());
260        let content = output.into_content();
261        assert!(matches!(
262            content.first(),
263            ToolResultContent::Json { value: content_value } if content_value == value
264        ));
265    }
266
267    #[test]
268    fn explicit_json_string_is_distinct_from_literal_text() {
269        let explicit = serde_json::Value::String("hello".to_string());
270
271        let json_output = explicit.clone().into_tool_output().unwrap();
272        let text_output = "hello".to_string().into_tool_output().unwrap();
273
274        assert_eq!(json_output, ToolOutput::json(explicit.clone()));
275        assert_eq!(json_output.as_json(), Some(&explicit));
276        assert_eq!(json_output.as_text(), None);
277        assert_eq!(text_output, ToolOutput::text("hello"));
278        assert_eq!(text_output.as_text(), Some("hello"));
279    }
280
281    #[test]
282    fn explicit_image_content_preserves_its_type() {
283        let image =
284            ToolResultContent::image_base64("base64data==", Some(ImageMediaType::JPEG), None);
285        let output = image.into_tool_output().unwrap();
286
287        let content = output.into_content();
288        assert!(matches!(
289            content.first(),
290            ToolResultContent::Image(image)
291                if image.media_type == Some(ImageMediaType::JPEG)
292                    && matches!(&image.data, DocumentSourceKind::Base64(data) if data == "base64data==")
293        ));
294    }
295
296    #[test]
297    fn direct_ordered_content_is_not_serialized_as_json() {
298        let content = OneOrMany::many(vec![
299            ToolResultContent::text("before"),
300            ToolResultContent::image_base64("base64data==", Some(ImageMediaType::PNG), None),
301            ToolResultContent::json(serde_json::json!({"after": true})),
302        ])
303        .unwrap();
304
305        let output = content.clone().into_tool_output().unwrap();
306
307        assert_eq!(output.as_content(), &content);
308    }
309
310    #[test]
311    fn singleton_plain_content_has_one_canonical_representation() {
312        assert_eq!(
313            ToolOutput::text("hello"),
314            ToolOutput::one(ToolResultContent::text("hello"))
315        );
316        assert_eq!(
317            ToolOutput::json(serde_json::json!({"ok": true})),
318            ToolOutput::one(ToolResultContent::json(serde_json::json!({"ok": true})))
319        );
320    }
321}