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::{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: Vec<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    ///
58    /// Rejects an empty list. On `main` the argument type made emptiness
59    /// unrepresentable; as a `Vec` the check lives here instead, because this
60    /// is the one funnel every multi-block construction passes through —
61    /// tools returning [`ToolOutput`] directly and hooks rewriting one
62    /// included. A zero-block tool result cannot be sent (the request
63    /// boundary rejects it), so rejecting at construction surfaces the
64    /// defect where it is made rather than one request later. A tool with a
65    /// genuinely empty result returns one empty text block ([`Self::text`]
66    /// with `""`).
67    pub fn content(content: Vec<ToolResultContent>) -> Result<Self, ToolExecutionError> {
68        let content = crate::message::require_non_empty(content, || {
69            ToolExecutionError::other(
70                "tool output has no content blocks; return at least one block — \
71                 an empty text block is valid",
72            )
73        })?;
74        Ok(Self { content })
75    }
76
77    /// Construct one explicit model-content block.
78    pub fn one(content: ToolResultContent) -> Self {
79        Self {
80            content: vec![content],
81        }
82    }
83
84    /// Return literal text when this output is exactly one plain text block.
85    pub fn as_text(&self) -> Option<&str> {
86        if self.content.len() != 1 {
87            return None;
88        }
89
90        match self.content.first()? {
91            // `Some` params always carry data (`AdditionalParams` is
92            // non-empty by construction), so plain `is_none` is the whole
93            // annotation check.
94            ToolResultContent::Text(text) if text.additional_params.is_none() => Some(&text.text),
95            ToolResultContent::Text(_)
96            | ToolResultContent::Image(_)
97            | ToolResultContent::Json { .. } => None,
98        }
99    }
100
101    /// Return structured JSON when this output is exactly one JSON block.
102    pub fn as_json(&self) -> Option<&serde_json::Value> {
103        if self.content.len() != 1 {
104            return None;
105        }
106
107        match self.content.first()? {
108            ToolResultContent::Json { value } => Some(value),
109            ToolResultContent::Text(_) | ToolResultContent::Image(_) => None,
110        }
111    }
112
113    /// Borrow the canonical ordered content blocks.
114    pub fn as_content(&self) -> &[ToolResultContent] {
115        &self.content
116    }
117
118    /// Convert this output into the canonical message content sent to a model.
119    pub fn into_content(self) -> Vec<ToolResultContent> {
120        self.content
121    }
122
123    /// Render a stable text representation for telemetry and diagnostics.
124    ///
125    /// This is a terminal rendering operation; the returned text is never used
126    /// to reconstruct structured output.
127    pub fn render(&self) -> String {
128        if let Some(text) = self.as_text() {
129            text.to_string()
130        } else if let Some(value) = self.as_json() {
131            value.to_string()
132        } else {
133            serde_json::to_string(&self.content)
134                .unwrap_or_else(|_| "<structured tool output>".to_string())
135        }
136    }
137}
138
139impl From<String> for ToolOutput {
140    fn from(text: String) -> Self {
141        Self::text(text)
142    }
143}
144
145impl From<&str> for ToolOutput {
146    fn from(text: &str) -> Self {
147        Self::text(text)
148    }
149}
150
151impl From<serde_json::Value> for ToolOutput {
152    fn from(value: serde_json::Value) -> Self {
153        Self::json(value)
154    }
155}
156
157impl From<ToolResultContent> for ToolOutput {
158    fn from(content: ToolResultContent) -> Self {
159        Self::one(content)
160    }
161}
162
163impl TryFrom<Vec<ToolResultContent>> for ToolOutput {
164    type Error = ToolExecutionError;
165
166    // `From` on `main` — the source type was non-empty by construction, so the
167    // conversion could not fail. With `Vec` the emptiness check makes it
168    // fallible; a `From` here would be the unguarded bypass around
169    // [`ToolOutput::content`].
170    fn try_from(content: Vec<ToolResultContent>) -> Result<Self, Self::Error> {
171        Self::content(content)
172    }
173}
174
175/// Conversion into Rig's canonical tool output.
176///
177/// A blanket implementation keeps ordinary [`Serialize`] outputs ergonomic.
178/// Because that blanket implementation already covers every serializable type,
179/// it cannot be overridden with another implementation for a serializable
180/// custom type. Return [`ToolOutput`] from [`PortableTool::call`](crate::tool::PortableTool::call)
181/// when that type needs a custom presentation. Implement this trait directly
182/// only for output types that do not implement [`Serialize`].
183pub trait IntoToolOutput {
184    /// Convert this value without routing structured data through a string.
185    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError>;
186}
187
188#[cfg(test)]
189mod debug_tests {
190    use crate::message::ImageMediaType;
191
192    use super::*;
193
194    #[test]
195    fn debug_reports_shape_without_tool_content() {
196        let output = ToolOutput::content(vec![
197            ToolResultContent::text("Bearer secret-tool-output"),
198            ToolResultContent::json(serde_json::json!({
199                "credential": "secret-json-output"
200            })),
201            ToolResultContent::image_base64("secret-image-output", Some(ImageMediaType::PNG), None),
202        ])
203        .expect("fixture content is non-empty");
204
205        let debug = format!("{output:?}");
206        assert!(debug.contains("content_count: 3"));
207        assert!(debug.contains("text"));
208        assert!(debug.contains("json"));
209        assert!(debug.contains("image"));
210        for secret in [
211            "secret-tool-output",
212            "secret-json-output",
213            "secret-image-output",
214        ] {
215            assert!(!debug.contains(secret));
216        }
217    }
218}
219
220impl<T> IntoToolOutput for T
221where
222    T: Serialize + 'static,
223{
224    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError> {
225        // `ToolResultContent` and `Vec<ToolResultContent>` are serializable
226        // because they also serve as transcript types. They nevertheless mean
227        // explicit rich output here; serializing them through the fallback would
228        // silently turn an image into a JSON object. Stable Rust cannot express
229        // a blanket `Serialize` impl with negative exceptions, so preserve these
230        // two canonical rich types before taking the serialization path.
231        let value = &self as &dyn Any;
232        if let Some(content) = value.downcast_ref::<ToolResultContent>() {
233            return Ok(ToolOutput::one(content.clone()));
234        }
235        if let Some(content) = value.downcast_ref::<Vec<ToolResultContent>>() {
236            // `ToolOutput::content` rejects an empty list, so an empty
237            // rich-content return surfaces here as a normal tool failure the
238            // agent feeds back to the model, instead of a zero-block result
239            // entering history and aborting the whole run at the next
240            // request's boundary validation. Deliberately not normalized to
241            // an empty text block: inventing content the tool never produced
242            // is the fabrication this crate removed.
243            return ToolOutput::content(content.clone());
244        }
245        let is_explicit_json = value.is::<serde_json::Value>();
246
247        serde_json::to_value(self)
248            .map(|value| match value {
249                serde_json::Value::String(text) if !is_explicit_json => ToolOutput::text(text),
250                value => ToolOutput::json(value),
251            })
252            .map_err(|error| {
253                ToolExecutionError::other(format!("failed to serialize tool output: {error}"))
254                    .with_source(error)
255            })
256    }
257}
258
259impl IntoToolOutput for ToolOutput {
260    fn into_tool_output(self) -> Result<ToolOutput, ToolExecutionError> {
261        Ok(self)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use crate::message::{DocumentSourceKind, ImageMediaType};
268
269    use super::*;
270
271    #[test]
272    fn an_empty_content_list_cannot_become_a_tool_output() {
273        // A zero-block tool result cannot be sent — the request boundary
274        // rejects it — so the failure surfaces at construction as an ordinary
275        // tool error instead of aborting the run one request later. Every
276        // route is closed: the rich-content tool return, the explicit
277        // constructor, and the fallible conversion. One empty text block, by
278        // contrast, is a legitimate empty result and passes.
279        let error = Vec::<ToolResultContent>::new()
280            .into_tool_output()
281            .expect_err("an empty rich-content list must not become a ToolOutput");
282        assert!(error.to_string().contains("no content blocks"));
283
284        assert!(ToolOutput::content(Vec::new()).is_err());
285        assert!(ToolOutput::try_from(Vec::<ToolResultContent>::new()).is_err());
286
287        let output = vec![ToolResultContent::text("")]
288            .into_tool_output()
289            .unwrap();
290        assert_eq!(output, ToolOutput::text(""));
291    }
292
293    #[test]
294    fn json_shaped_strings_remain_literal_text() {
295        let text = r#"{"type":"image","data":"not-an-envelope"}"#.to_string();
296        let output = text.clone().into_tool_output().unwrap();
297
298        assert_eq!(output, ToolOutput::text(text.clone()));
299        let content = output.into_content();
300        assert!(
301            matches!(content.first(), Some(ToolResultContent::Text(value)) if value.text == text)
302        );
303    }
304
305    #[test]
306    fn structured_values_remain_json_until_terminal_rendering() {
307        let value = serde_json::json!({"status": "ok", "count": 2});
308        let output = value.clone().into_tool_output().unwrap();
309
310        assert_eq!(output, ToolOutput::json(value.clone()));
311        assert_eq!(output.render(), value.to_string());
312        let content = output.into_content();
313        assert!(matches!(
314            content.first(),
315            Some(ToolResultContent::Json { value: content_value }) if *content_value == value
316        ));
317    }
318
319    #[test]
320    fn explicit_json_string_is_distinct_from_literal_text() {
321        let explicit = serde_json::Value::String("hello".to_string());
322
323        let json_output = explicit.clone().into_tool_output().unwrap();
324        let text_output = "hello".to_string().into_tool_output().unwrap();
325
326        assert_eq!(json_output, ToolOutput::json(explicit.clone()));
327        assert_eq!(json_output.as_json(), Some(&explicit));
328        assert_eq!(json_output.as_text(), None);
329        assert_eq!(text_output, ToolOutput::text("hello"));
330        assert_eq!(text_output.as_text(), Some("hello"));
331    }
332
333    #[test]
334    fn explicit_image_content_preserves_its_type() {
335        let image =
336            ToolResultContent::image_base64("base64data==", Some(ImageMediaType::JPEG), None);
337        let output = image.into_tool_output().unwrap();
338
339        let content = output.into_content();
340        assert!(matches!(
341            content.first(),
342            Some(ToolResultContent::Image(image))
343                if image.media_type == Some(ImageMediaType::JPEG)
344                    && matches!(&image.data, DocumentSourceKind::Base64(data) if data == "base64data==")
345        ));
346    }
347
348    #[test]
349    fn direct_ordered_content_is_not_serialized_as_json() {
350        let content = vec![
351            ToolResultContent::text("before"),
352            ToolResultContent::image_base64("base64data==", Some(ImageMediaType::PNG), None),
353            ToolResultContent::json(serde_json::json!({"after": true})),
354        ];
355
356        let output = content.clone().into_tool_output().unwrap();
357
358        assert_eq!(output.as_content(), &content);
359    }
360
361    #[test]
362    fn singleton_plain_content_has_one_canonical_representation() {
363        assert_eq!(
364            ToolOutput::text("hello"),
365            ToolOutput::one(ToolResultContent::text("hello"))
366        );
367        assert_eq!(
368            ToolOutput::json(serde_json::json!({"ok": true})),
369            ToolOutput::one(ToolResultContent::json(serde_json::json!({"ok": true})))
370        );
371    }
372}