Skip to main content

oharness_core/
message.rs

1//! Message & content types (§4.2).
2
3use crate::completion::StopReason;
4use crate::MetadataMap;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::path::PathBuf;
8use url::Url;
9
10/// A conversation message. Three roles; assistant turns carry a stop reason.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
13#[serde(tag = "role", rename_all = "snake_case")]
14pub enum Message {
15    System {
16        content: String,
17        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
18        meta: MetadataMap,
19    },
20    User {
21        content: Vec<Content>,
22        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
23        meta: MetadataMap,
24    },
25    Assistant {
26        content: Vec<Content>,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        stop_reason: Option<StopReason>,
29        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
30        meta: MetadataMap,
31    },
32}
33
34impl Message {
35    pub fn system(content: impl Into<String>) -> Self {
36        Self::System {
37            content: content.into(),
38            meta: MetadataMap::new(),
39        }
40    }
41
42    pub fn user_text(text: impl Into<String>) -> Self {
43        Self::User {
44            content: vec![Content::text(text)],
45            meta: MetadataMap::new(),
46        }
47    }
48
49    pub fn assistant_text(text: impl Into<String>) -> Self {
50        Self::Assistant {
51            content: vec![Content::text(text)],
52            stop_reason: None,
53            meta: MetadataMap::new(),
54        }
55    }
56}
57
58/// A single content block inside a message.
59///
60/// All variants are struct-shaped so the enum round-trips cleanly through
61/// serde's `#[serde(tag = "type")]` representation — serde refuses to
62/// serialize tagged newtype variants that wrap a primitive, which would
63/// otherwise silently break `llm.request` / `llm.response` event payloads.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
66#[serde(tag = "type", rename_all = "snake_case")]
67pub enum Content {
68    Text {
69        text: String,
70    },
71    ToolUse {
72        id: String,
73        name: String,
74        input: Value,
75    },
76    ToolResult {
77        tool_use_id: String,
78        output: ToolOutput,
79        #[serde(default)]
80        is_error: bool,
81    },
82    /// Extended-thinking blocks (Anthropic).
83    Thinking {
84        thinking: String,
85    },
86    Image(ImageRef),
87    Document(DocumentRef),
88    Audio(AudioRef),
89    Citation(CitationRef),
90}
91
92impl Content {
93    pub fn text(s: impl Into<String>) -> Self {
94        Self::Text { text: s.into() }
95    }
96
97    pub fn thinking(s: impl Into<String>) -> Self {
98        Self::Thinking { thinking: s.into() }
99    }
100}
101
102/// Structured output of a tool call. Tools can return rich content (text, images, etc.).
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
105pub struct ToolOutput {
106    pub content: Vec<Content>,
107    #[serde(default)]
108    pub truncated: bool,
109}
110
111impl ToolOutput {
112    pub fn text(s: impl Into<String>) -> Self {
113        Self {
114            content: vec![Content::text(s)],
115            truncated: false,
116        }
117    }
118}
119
120/// Reference to an image — inline bytes, URL, or file path. Research annotations live
121/// in `extensions` (reverse-DNS namespaced).
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
124#[serde(untagged)]
125pub enum ImageRef {
126    Url {
127        #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
128        url: Url,
129        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
130        extensions: MetadataMap,
131    },
132    File {
133        #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
134        path: PathBuf,
135        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
136        extensions: MetadataMap,
137    },
138    Inline {
139        mime: String,
140        bytes: Vec<u8>,
141        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
142        extensions: MetadataMap,
143    },
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
148#[serde(untagged)]
149pub enum DocumentRef {
150    Url {
151        #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
152        url: Url,
153        mime: Option<String>,
154        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
155        extensions: MetadataMap,
156    },
157    File {
158        #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
159        path: PathBuf,
160        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
161        extensions: MetadataMap,
162    },
163    Inline {
164        mime: String,
165        bytes: Vec<u8>,
166        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
167        extensions: MetadataMap,
168    },
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
173#[serde(untagged)]
174pub enum AudioRef {
175    Url {
176        #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
177        url: Url,
178        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
179        extensions: MetadataMap,
180    },
181    File {
182        #[cfg_attr(feature = "schemars-export", schemars(with = "String"))]
183        path: PathBuf,
184        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
185        extensions: MetadataMap,
186    },
187    Inline {
188        mime: String,
189        bytes: Vec<u8>,
190        #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
191        extensions: MetadataMap,
192    },
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[cfg_attr(feature = "schemars-export", derive(schemars::JsonSchema))]
197pub struct CitationRef {
198    pub source: String,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub quoted_text: Option<String>,
201    #[serde(default, skip_serializing_if = "MetadataMap::is_empty")]
202    pub extensions: MetadataMap,
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde_json::json;
209
210    fn round_trip(content: &Content) -> Content {
211        let bytes = serde_json::to_vec(content).expect("serialize Content");
212        serde_json::from_slice::<Content>(&bytes).expect("deserialize Content")
213    }
214
215    #[test]
216    fn text_serializes_as_tagged_struct() {
217        let c = Content::text("hello");
218        let v = serde_json::to_value(&c).expect("to_value");
219        assert_eq!(v, json!({"type": "text", "text": "hello"}));
220        assert!(matches!(round_trip(&c), Content::Text { text } if text == "hello"));
221    }
222
223    #[test]
224    fn thinking_serializes_as_tagged_struct() {
225        let c = Content::thinking("hmm");
226        let v = serde_json::to_value(&c).expect("to_value");
227        assert_eq!(v, json!({"type": "thinking", "thinking": "hmm"}));
228        assert!(matches!(round_trip(&c), Content::Thinking { thinking } if thinking == "hmm"));
229    }
230
231    #[test]
232    fn tool_use_round_trips() {
233        let c = Content::ToolUse {
234            id: "tu_1".into(),
235            name: "fs_list".into(),
236            input: json!({"path": "."}),
237        };
238        let v = serde_json::to_value(&c).expect("to_value");
239        assert_eq!(v["type"], "tool_use");
240        assert_eq!(v["id"], "tu_1");
241        assert_eq!(v["name"], "fs_list");
242        assert_eq!(v["input"], json!({"path": "."}));
243        assert!(matches!(round_trip(&c), Content::ToolUse { id, .. } if id == "tu_1"));
244    }
245
246    #[test]
247    fn tool_result_round_trips() {
248        let c = Content::ToolResult {
249            tool_use_id: "tu_1".into(),
250            output: ToolOutput::text("ok"),
251            is_error: false,
252        };
253        let bytes = serde_json::to_vec(&c).expect("serialize");
254        let back: Content = serde_json::from_slice(&bytes).expect("deserialize");
255        match back {
256            Content::ToolResult {
257                tool_use_id,
258                output,
259                is_error,
260            } => {
261                assert_eq!(tool_use_id, "tu_1");
262                assert!(!is_error);
263                assert!(!output.truncated);
264                assert!(
265                    matches!(&output.content[..], [Content::Text { text }] if text == "ok"),
266                    "output content: {:?}",
267                    output.content
268                );
269            }
270            other => panic!("expected ToolResult, got {other:?}"),
271        }
272    }
273
274    #[test]
275    fn message_with_mixed_content_round_trips() {
276        let msg = Message::Assistant {
277            content: vec![
278                Content::text("Here's what I found:"),
279                Content::ToolUse {
280                    id: "tu_1".into(),
281                    name: "fs_list".into(),
282                    input: json!({"path": "."}),
283                },
284            ],
285            stop_reason: Some(StopReason::ToolUse),
286            meta: MetadataMap::new(),
287        };
288        let bytes = serde_json::to_vec(&msg).expect("serialize Message");
289        let back: Message = serde_json::from_slice(&bytes).expect("deserialize Message");
290        match back {
291            Message::Assistant { content, .. } => assert_eq!(content.len(), 2),
292            other => panic!("expected Assistant, got {other:?}"),
293        }
294    }
295}