Skip to main content

infinity_provider_protocol/
message.rs

1//! Chat message types exchanged between the agent runtime and model
2//! providers.
3//!
4//! These types replace the subset of `rig`'s message model that this project
5//! actually uses. Their JSON representations are **byte-compatible** with the
6//! rig types they replace (rig 0.31), because persisted conversation
7//! histories embed them — do not change serde attributes without a migration
8//! plan. Fields rig serialized but this project never populated (e.g. tool
9//! call signatures) have been dropped; they deserialize as ignored unknown
10//! fields from old data.
11
12use serde::{Deserialize, Serialize};
13
14/// A message in a conversation: one run of user input or assistant output.
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "role", rename_all = "lowercase")]
17pub enum Message {
18    /// User message (plain content or tool results).
19    User { content: Vec<UserContent> },
20    /// Assistant message (text, reasoning, or tool calls).
21    Assistant { content: Vec<AssistantContent> },
22}
23
24impl Message {
25    /// A user message containing a single text block.
26    pub fn user(text: impl Into<String>) -> Self {
27        Message::User {
28            content: vec![UserContent::text(text)],
29        }
30    }
31
32    /// An assistant message containing a single text block.
33    pub fn assistant(text: impl Into<String>) -> Self {
34        Message::Assistant {
35            content: vec![AssistantContent::text(text)],
36        }
37    }
38}
39
40/// Content of a user message.
41#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "lowercase")]
43pub enum UserContent {
44    Text(Text),
45    ToolResult(ToolResult),
46    Image(Image),
47}
48
49impl UserContent {
50    /// A plain text user content block.
51    pub fn text(text: impl Into<String>) -> Self {
52        UserContent::Text(Text { text: text.into() })
53    }
54}
55
56/// Content of an assistant message.
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(untagged)]
59pub enum AssistantContent {
60    Text(Text),
61    ToolCall(ToolCall),
62    Reasoning(Reasoning),
63}
64
65impl AssistantContent {
66    /// A plain text assistant content block.
67    pub fn text(text: impl Into<String>) -> Self {
68        AssistantContent::Text(Text { text: text.into() })
69    }
70}
71
72/// A plain text block.
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct Text {
75    pub text: String,
76}
77
78impl From<String> for Text {
79    fn from(text: String) -> Self {
80        Text { text }
81    }
82}
83
84impl From<&str> for Text {
85    fn from(text: &str) -> Self {
86        Text { text: text.into() }
87    }
88}
89
90impl std::fmt::Display for Text {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "{}", self.text)
93    }
94}
95
96/// A tool invocation requested by the model.
97#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
98pub struct ToolCall {
99    /// Provider-supplied tool call id, echoed back in the [`ToolResult`].
100    pub id: String,
101    /// Secondary call id used by some providers; `None` for Bedrock.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub call_id: Option<String>,
104    pub function: ToolFunction,
105}
106
107impl ToolCall {
108    pub fn new(
109        id: impl Into<String>,
110        name: impl Into<String>,
111        arguments: serde_json::Value,
112    ) -> Self {
113        ToolCall {
114            id: id.into(),
115            call_id: None,
116            function: ToolFunction {
117                name: name.into(),
118                arguments,
119            },
120        }
121    }
122}
123
124/// The function name and arguments of a [`ToolCall`].
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126pub struct ToolFunction {
127    pub name: String,
128    pub arguments: serde_json::Value,
129}
130
131/// The result of executing a tool call, sent back as user content.
132#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133pub struct ToolResult {
134    /// The id of the [`ToolCall`] this result answers.
135    pub id: String,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub call_id: Option<String>,
138    pub content: Vec<ToolResultContent>,
139}
140
141/// Content of a [`ToolResult`]: text or an image.
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143#[serde(tag = "type", rename_all = "lowercase")]
144pub enum ToolResultContent {
145    Text(Text),
146    Image(Image),
147}
148
149impl ToolResultContent {
150    /// A plain text tool result block.
151    pub fn text(text: impl Into<String>) -> Self {
152        ToolResultContent::Text(Text { text: text.into() })
153    }
154}
155
156/// An assistant reasoning ("thinking") block.
157#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
158pub struct Reasoning {
159    /// Provider reasoning identifier, when supplied by the upstream API.
160    pub id: Option<String>,
161    /// Ordered reasoning content blocks.
162    pub content: Vec<ReasoningContent>,
163}
164
165impl Reasoning {
166    /// A single text reasoning block with an optional provider signature.
167    pub fn new_with_signature(text: impl Into<String>, signature: Option<String>) -> Self {
168        Reasoning {
169            id: None,
170            content: vec![ReasoningContent::Text {
171                text: text.into(),
172                signature,
173            }],
174        }
175    }
176
177    /// The first text reasoning block, if present.
178    pub fn first_text(&self) -> Option<&str> {
179        self.content.iter().find_map(|content| match content {
180            ReasoningContent::Text { text, .. } => Some(text.as_str()),
181            _ => None,
182        })
183    }
184
185    /// The first signature from text reasoning, if present.
186    pub fn first_signature(&self) -> Option<&str> {
187        self.content.iter().find_map(|content| match content {
188            ReasoningContent::Text {
189                signature: Some(signature),
190                ..
191            } => Some(signature.as_str()),
192            _ => None,
193        })
194    }
195
196    /// Render reasoning as displayable text by joining text-like blocks with
197    /// newlines.
198    pub fn display_text(&self) -> String {
199        self.content
200            .iter()
201            .filter_map(|content| match content {
202                ReasoningContent::Text { text, .. } => Some(text.as_str()),
203                ReasoningContent::Summary(summary) => Some(summary.as_str()),
204                ReasoningContent::Redacted { data } => Some(data.as_str()),
205                ReasoningContent::Encrypted(_) => None,
206            })
207            .collect::<Vec<_>>()
208            .join("\n")
209    }
210}
211
212/// One block of reasoning content.
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214#[serde(tag = "type", content = "content", rename_all = "snake_case")]
215pub enum ReasoningContent {
216    /// Plain reasoning text with an optional provider signature.
217    Text {
218        text: String,
219        #[serde(default, skip_serializing_if = "Option::is_none")]
220        signature: Option<String>,
221    },
222    /// Provider-encrypted reasoning payload.
223    Encrypted(String),
224    /// Redacted reasoning payload preserved as opaque data.
225    Redacted { data: String },
226    /// Provider-generated reasoning summary text.
227    Summary(String),
228}
229
230/// Image content with its source data and media type.
231#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
232pub struct Image {
233    pub data: ImageSource,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub media_type: Option<ImageMediaType>,
236}
237
238/// Where an [`Image`]'s data comes from.
239///
240/// Serde representation matches rig's `DocumentSourceKind` (which this type
241/// replaces), since images are embedded in persisted tool results.
242#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
243#[serde(tag = "type", content = "value", rename_all = "camelCase")]
244pub enum ImageSource {
245    /// A URL pointing at the image.
246    Url(String),
247    /// Base64-encoded image bytes.
248    Base64(String),
249}
250
251/// Image media type.
252#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "lowercase")]
254pub enum ImageMediaType {
255    JPEG,
256    PNG,
257    GIF,
258    WEBP,
259    HEIC,
260    HEIF,
261    SVG,
262}
263
264impl ImageMediaType {
265    pub fn from_mime_type(mime_type: &str) -> Option<Self> {
266        match mime_type {
267            "image/jpeg" => Some(ImageMediaType::JPEG),
268            "image/png" => Some(ImageMediaType::PNG),
269            "image/gif" => Some(ImageMediaType::GIF),
270            "image/webp" => Some(ImageMediaType::WEBP),
271            "image/heic" => Some(ImageMediaType::HEIC),
272            "image/heif" => Some(ImageMediaType::HEIF),
273            "image/svg+xml" => Some(ImageMediaType::SVG),
274            _ => None,
275        }
276    }
277
278    pub fn to_mime_type(&self) -> &'static str {
279        match self {
280            ImageMediaType::JPEG => "image/jpeg",
281            ImageMediaType::PNG => "image/png",
282            ImageMediaType::GIF => "image/gif",
283            ImageMediaType::WEBP => "image/webp",
284            ImageMediaType::HEIC => "image/heic",
285            ImageMediaType::HEIF => "image/heif",
286            ImageMediaType::SVG => "image/svg+xml",
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    /// Assert `value` serializes to exactly `json` and deserializes back.
296    #[track_caller]
297    fn assert_round_trip<T>(value: &T, json: &str)
298    where
299        T: Serialize + for<'de> Deserialize<'de> + PartialEq + std::fmt::Debug,
300    {
301        let serialized = serde_json::to_string(value).expect("serialize");
302        assert_eq!(serialized, json);
303        let deserialized: T = serde_json::from_str(json).expect("deserialize");
304        assert_eq!(&deserialized, value);
305    }
306
307    // The JSON strings in these tests are the exact formats rig 0.31
308    // produced; persisted histories contain them. Do not update the
309    // expectations without a data migration.
310
311    #[test]
312    fn user_text_message_format() {
313        assert_round_trip(
314            &Message::user("hi"),
315            r#"{"role":"user","content":[{"type":"text","text":"hi"}]}"#,
316        );
317    }
318
319    #[test]
320    fn assistant_text_message_format() {
321        assert_round_trip(
322            &Message::assistant("hello"),
323            r#"{"role":"assistant","content":[{"text":"hello"}]}"#,
324        );
325    }
326
327    #[test]
328    fn tool_call_format() {
329        assert_round_trip(
330            &AssistantContent::ToolCall(ToolCall::new(
331                "call-1",
332                "get_weather",
333                serde_json::json!({"city": "Berlin"}),
334            )),
335            r#"{"id":"call-1","function":{"name":"get_weather","arguments":{"city":"Berlin"}}}"#,
336        );
337    }
338
339    #[test]
340    fn tool_result_format() {
341        assert_round_trip(
342            &UserContent::ToolResult(ToolResult {
343                id: "call-1".to_owned(),
344                call_id: None,
345                content: vec![ToolResultContent::text("ok")],
346            }),
347            r#"{"type":"toolresult","id":"call-1","content":[{"type":"text","text":"ok"}]}"#,
348        );
349    }
350
351    #[test]
352    fn reasoning_format() {
353        assert_round_trip(
354            &AssistantContent::Reasoning(Reasoning::new_with_signature(
355                "thinking...",
356                Some("sig".to_owned()),
357            )),
358            r#"{"id":null,"content":[{"type":"text","content":{"text":"thinking...","signature":"sig"}}]}"#,
359        );
360    }
361
362    #[test]
363    fn reasoning_content_variants_round_trip() {
364        assert_round_trip(
365            &ReasoningContent::Encrypted("opaque".to_owned()),
366            r#"{"type":"encrypted","content":"opaque"}"#,
367        );
368        assert_round_trip(
369            &ReasoningContent::Redacted {
370                data: "r".to_owned(),
371            },
372            r#"{"type":"redacted","content":{"data":"r"}}"#,
373        );
374        assert_round_trip(
375            &ReasoningContent::Summary("s".to_owned()),
376            r#"{"type":"summary","content":"s"}"#,
377        );
378    }
379
380    #[test]
381    fn image_format() {
382        assert_round_trip(
383            &UserContent::Image(Image {
384                data: ImageSource::Base64("aGVsbG8=".to_owned()),
385                media_type: Some(ImageMediaType::PNG),
386            }),
387            r#"{"type":"image","data":{"type":"base64","value":"aGVsbG8="},"media_type":"png"}"#,
388        );
389    }
390}