1use std::{any::Any, fmt};
4
5use serde::Serialize;
6
7use crate::{message::ToolResultContent, tool::ToolExecutionError};
8
9#[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 pub fn text(text: impl Into<String>) -> Self {
45 Self::one(ToolResultContent::text(text))
46 }
47
48 pub fn json(value: serde_json::Value) -> Self {
53 Self::one(ToolResultContent::json(value))
54 }
55
56 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 pub fn one(content: ToolResultContent) -> Self {
79 Self {
80 content: vec![content],
81 }
82 }
83
84 pub fn as_text(&self) -> Option<&str> {
86 if self.content.len() != 1 {
87 return None;
88 }
89
90 match self.content.first()? {
91 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 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 pub fn as_content(&self) -> &[ToolResultContent] {
115 &self.content
116 }
117
118 pub fn into_content(self) -> Vec<ToolResultContent> {
120 self.content
121 }
122
123 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 fn try_from(content: Vec<ToolResultContent>) -> Result<Self, Self::Error> {
171 Self::content(content)
172 }
173}
174
175pub trait IntoToolOutput {
184 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 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 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 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}