protocol_droid/domain/ir/tool.rs
1//! `ToolResult` — the value T07's named scenario is about.
2//!
3//! # The scenario
4//!
5//! `structured-output-downlevel`: a `2025-06-18` server returns
6//! `structuredContent`, and the downstream client negotiated `2025-03-26`,
7//! which has no such field. The specification's recommended fallback is to
8//! serialise the object and emit it as a text content block, and to record the
9//! downgrade.
10//!
11//! The fallback lives here rather than in T08's encoder because it is a
12//! *content* decision, not a wire one: the same downgraded result is valid on
13//! any revision that has text content, and T08's job is only to check that.
14//! Putting it in the encoder would mean two encoder paths — one that falls
15//! back and one that does not — and the second would be the one that gets used
16//! by mistake.
17//!
18//! # Why appending rather than replacing
19//!
20//! [`ToolResult::downgrade_structured_output`] appends the serialised JSON as
21//! a text block and *clears* `structured_content`. Appending preserves the
22//! result's own content, which is the part the tool meant to be read; clearing
23//! prevents a lowering bug from leaving a field on a revision that has no field
24//! for it. The returned [`Downgrade`] names the feature lost, so the caller can
25//! put it in a `TranslationReport` without re-deriving it.
26
27use crate::domain::Feature;
28use crate::domain::ir::content::{Content, TextContent};
29use crate::domain::ir::extensions::Extensions;
30use crate::domain::ir::features::FeatureSet;
31use crate::domain::ir::json::JsonObject;
32
33/// The result of calling a tool.
34#[derive(Debug, Clone, PartialEq, Default)]
35pub struct ToolResult {
36 /// The result's content blocks.
37 pub content: Vec<Content>,
38 /// Structured output, `2025-06-18` and later (PR #371).
39 pub structured_content: Option<JsonObject>,
40 /// Whether the call failed. Every revision.
41 pub is_error: bool,
42 /// The result's type, `2026-07-28` and later.
43 ///
44 /// Required rather than optional from `2026-07-28`, where the schema
45 /// types it `String` and not `Option<String>`. That asymmetry is why this
46 /// is `Option` here and still needs a value on the wire: the IR is a
47 /// superset, so it cannot make a field mandatory for a revision that
48 /// predates it — but a codec targeting `2026-07-28` does have to supply
49 /// one, and that is a lowering decision rather than a lift.
50 pub result_type: Option<String>,
51 /// Unmodelled fields.
52 pub extensions: Extensions,
53}
54
55impl ToolResult {
56 /// A result with the given content and no structured output.
57 #[must_use]
58 pub const fn new(content: Vec<Content>) -> Self {
59 Self {
60 content,
61 structured_content: None,
62 is_error: false,
63 result_type: None,
64 extensions: Extensions::empty(),
65 }
66 }
67
68 /// A result that is only text.
69 #[must_use]
70 pub fn text(text: impl Into<String>) -> Self {
71 Self::new(vec![Content::Text(TextContent::new(text))])
72 }
73
74 /// Attaches structured output, which is what makes this a
75 /// `2025-06-18`-or-later value.
76 ///
77 /// # Errors
78 ///
79 /// [`JsonObjectError`](crate::domain::ir::JsonObjectError) when `json` is
80 /// not a JSON object.
81 pub fn with_structured(
82 mut self,
83 json: impl Into<String>,
84 ) -> Result<Self, crate::domain::ir::JsonObjectError> {
85 self.structured_content = Some(JsonObject::new(json)?);
86 Ok(self)
87 }
88
89 /// Marks the result as an error.
90 #[must_use]
91 pub const fn error(mut self) -> Self {
92 self.is_error = true;
93 self
94 }
95
96 /// The features this result needs in order to be expressed.
97 ///
98 /// The union of what every content block needs, plus
99 /// [`Feature::ToolStructuredOutput`] when there is structured output. Both
100 /// halves matter: a result of three audio blocks needs
101 /// `ToolAudioContent` exactly as much as a lone one does.
102 #[must_use]
103 pub fn required_features(&self) -> FeatureSet {
104 let mut required = FeatureSet::empty();
105 for block in &self.content {
106 required = required.union(block.required_features());
107 }
108 if self.structured_content.is_some() {
109 required = required.union(FeatureSet::empty().with(Feature::ToolStructuredOutput));
110 }
111 if self.result_type.is_some() {
112 required = required.union(FeatureSet::empty().with(Feature::ToolResultType));
113 }
114 required
115 }
116
117 /// Whether this result can be expressed without losing anything.
118 ///
119 /// This is the question lowering asks, and it is *not* the same as
120 /// "the session has the features" — a result with no structured output and
121 /// no exotic content needs nothing and is lowerable everywhere.
122 #[must_use]
123 pub fn is_lossless_anywhere(&self) -> bool {
124 self.required_features().is_empty()
125 }
126
127 /// Applies the `structured-output-downlevel` fallback.
128 ///
129 /// Appends the serialised `structuredContent` as a text content block and
130 /// clears the field. Returns `None` when there is nothing to downgrade, so
131 /// a caller can write `if let Some(downgrade) = ...` and only record a
132 /// report when a real change happened — a result that was already
133 /// lowerable must not produce a spurious entry in a `TranslationReport`.
134 ///
135 /// # Examples
136 ///
137 /// ```
138 /// use protocol_droid::domain::ir::{Content, ToolResult};
139 ///
140 /// let mut result = ToolResult::text("done").with_structured(r#"{"ok":true}"#)?;
141 /// assert_eq!(result.required_features().len(), 1);
142 ///
143 /// let downgrade = result.downgrade_structured_output().unwrap();
144 /// assert_eq!(downgrade.lost, protocol_droid::domain::Feature::ToolStructuredOutput);
145 /// // The original text survived, and the JSON was appended.
146 /// assert_eq!(result.content.len(), 2);
147 /// assert!(result.structured_content.is_none());
148 /// // And it is now expressible at every revision.
149 /// assert!(result.is_lossless_anywhere());
150 /// # Ok::<(), protocol_droid::domain::ir::JsonObjectError>(())
151 /// ```
152 #[must_use]
153 pub fn downgrade_structured_output(&mut self) -> Option<Downgrade> {
154 let structured = self.structured_content.take()?;
155 let json = structured.into_string();
156 self.content.push(Content::Text(TextContent::new(json)));
157 Some(Downgrade {
158 lost: Feature::ToolStructuredOutput,
159 note: "structuredContent serialised into a text content block",
160 })
161 }
162}
163
164/// A change lowering made to make a value expressible.
165///
166/// Describes what happened; the value itself is changed in place, so there is
167/// nothing to hand back.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct Downgrade {
170 /// The feature that was given up.
171 pub lost: Feature,
172 /// A human-readable description, for the `TranslationReport`.
173 pub note: &'static str,
174}
175
176#[cfg(test)]
177mod tests {
178 #![expect(
179 clippy::panic,
180 clippy::unwrap_used,
181 reason = "tests assert on fallible calls and unwrap with the failing precondition in the message"
182 )]
183
184 use super::ToolResult;
185 use crate::domain::Feature;
186 use crate::domain::ir::content::{AudioContent, Content, ImageContent, TextContent};
187
188 #[test]
189 fn a_plain_text_result_needs_nothing() {
190 let result = ToolResult::text("hello");
191 assert!(result.required_features().is_empty());
192 assert!(result.is_lossless_anywhere());
193 assert!(!result.is_error);
194 }
195
196 #[test]
197 fn structured_output_needs_exactly_one_feature() {
198 let result = ToolResult::text("x").with_structured(r#"{"a":1}"#).unwrap();
199 let required = result.required_features();
200 assert!(required.contains(Feature::ToolStructuredOutput));
201 assert_eq!(required.len(), 1);
202 assert!(!result.is_lossless_anywhere());
203 }
204
205 /// The union, not the max: a result with audio *and* structured output
206 /// needs both, and a caller that checked only the first would emit a field
207 /// the target revision has no place for.
208 #[test]
209 fn requirements_union_across_blocks_and_fields() {
210 let result = ToolResult::new(vec![
211 Content::Text(TextContent::new("x")),
212 Content::Image(ImageContent::new("A", "image/png")),
213 Content::Audio(AudioContent::new("A", "audio/wav")),
214 ])
215 .with_structured(r#"{"a":1}"#)
216 .unwrap();
217 let required = result.required_features();
218 assert_eq!(required.len(), 2);
219 assert!(required.contains(Feature::ToolAudioContent));
220 assert!(required.contains(Feature::ToolStructuredOutput));
221 }
222
223 #[test]
224 fn duplicates_of_the_same_block_need_only_one_feature() {
225 let result = ToolResult::new(vec![
226 Content::Audio(AudioContent::new("A", "audio/wav")),
227 Content::Audio(AudioContent::new("B", "audio/wav")),
228 Content::Audio(AudioContent::new("C", "audio/wav")),
229 ]);
230 assert_eq!(result.required_features().len(), 1);
231 }
232
233 /// The named scenario, end to end.
234 #[test]
235 fn structured_output_downlevel_scenario() {
236 let source = ToolResult::new(vec![Content::Text(TextContent::new("ok"))])
237 .with_structured(r#"{"temperature":0.7,"unit":"c"}"#)
238 .unwrap();
239
240 // Before: needs the structured-output feature, so a 2025-03-26 peer
241 // cannot be given it as-is.
242 assert!(
243 source
244 .required_features()
245 .contains(Feature::ToolStructuredOutput)
246 );
247
248 let mut result = source;
249 let downgrade = result.downgrade_structured_output().unwrap();
250 assert_eq!(downgrade.lost, Feature::ToolStructuredOutput);
251 assert!(!downgrade.note.is_empty());
252
253 // The field is gone, so there is nothing left for an encoder to
254 // wrongly emit.
255 assert!(result.structured_content.is_none());
256 // The tool's own content survived, and the JSON was appended.
257 assert_eq!(result.content.len(), 2);
258 let Some(Content::Text(added)) = result.content.get(1) else {
259 panic!("expected an appended text block");
260 };
261 assert_eq!(added.text, r#"{"temperature":0.7,"unit":"c"}"#);
262 // And it is now expressible at every revision, which is the whole
263 // point of the fallback.
264 assert!(result.is_lossless_anywhere());
265 }
266
267 /// A result with nothing to downgrade reports nothing, so a
268 /// `TranslationReport` does not fill with entries for values that were
269 /// already fine.
270 #[test]
271 fn a_result_with_nothing_to_downgrade_reports_nothing() {
272 assert!(
273 ToolResult::text("x")
274 .downgrade_structured_output()
275 .is_none()
276 );
277 }
278
279 /// The downgrade is idempotent in the way that matters: applying it twice
280 /// cannot append the JSON twice, because the second call has nothing left
281 /// to take.
282 #[test]
283 fn downgrading_twice_appends_once() {
284 let mut once = ToolResult::text("x").with_structured(r#"{"a":1}"#).unwrap();
285 assert!(once.downgrade_structured_output().is_some());
286 assert!(once.downgrade_structured_output().is_none());
287 assert_eq!(once.content.len(), 2);
288 }
289
290 #[test]
291 fn the_error_flag_survives_a_downgrade() {
292 let mut failed = ToolResult::text("boom")
293 .error()
294 .with_structured(r#"{"e":1}"#)
295 .unwrap();
296 assert!(failed.downgrade_structured_output().is_some());
297 assert!(failed.is_error);
298 }
299
300 /// The appended text is the exact bytes that were received, which is what
301 /// makes the fallback lossless rather than lossy-but-readable.
302 #[test]
303 fn the_appended_text_is_byte_identical() {
304 let json = r#"{ "unit" : "c" , "n" : 1.0 }"#;
305 let mut downgraded = ToolResult::text("x").with_structured(json).unwrap();
306 assert!(downgraded.downgrade_structured_output().is_some());
307 let Some(Content::Text(added)) = downgraded.content.get(1) else {
308 panic!("expected an appended text block");
309 };
310 assert_eq!(added.text, json);
311 }
312
313 #[test]
314 fn a_non_object_is_refused_at_construction() {
315 assert!(ToolResult::text("x").with_structured("[]").is_err());
316 assert!(ToolResult::text("x").with_structured("{oops").is_err());
317 }
318}
319
320/// `resultType` is `2026-07-28`, so a result carrying one must not lower
321/// onto anything older. Before this the field was absent from the IR
322/// entirely and a result could not carry one at all.
323#[test]
324fn a_result_type_raises_the_requirement() {
325 let mut result = ToolResult::text("done");
326 assert!(!result.required_features().contains(Feature::ToolResultType));
327
328 result.result_type = Some("text".to_owned());
329 assert!(result.required_features().contains(Feature::ToolResultType));
330}
331
332/// The field is required on the `2026-07-28` wire, so the IR has to carry
333/// it as optional and the codec has to decide. This pins the asymmetry so
334/// nobody "fixes" it into a non-optional field and breaks every earlier
335/// revision in the process.
336#[test]
337fn result_type_is_optional_in_the_ir_though_required_on_2026_07_28() {
338 assert!(ToolResult::text("done").result_type.is_none());
339 let mut result = ToolResult::text("done");
340 result.result_type = Some("json".to_owned());
341 assert_eq!(result.result_type.as_deref(), Some("json"));
342}