Skip to main content

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}