Skip to main content

somatize_core/
message.rs

1//! The canonical conversation shape.
2//!
3//! One definition, in core, so that every layer agrees: a Python step, the
4//! Rust provider, the schema validator, the journal, and the report renderer.
5//! Provider-specific wire formats are converted at the edge (in `soma-llm`),
6//! never leaked inwards.
7//!
8//! This exists because of what the failure data says. Across 1600+ annotated
9//! multi-agent traces (MAST, NeurIPS 2025), ~37% of failures are inter-agent
10//! misalignment — context lost at a handoff, formats that don't line up.
11//! A shared message type plus a schema that can say "this edge carries
12//! messages" moves a chunk of that from runtime surprise to compile error.
13
14use crate::error::{Result, SomaError};
15use crate::value::Value;
16use serde::{Deserialize, Serialize};
17
18/// Who produced a message.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21#[non_exhaustive]
22pub enum Role {
23    /// Instructions that frame the conversation.
24    System,
25    /// The human — or the calling program — side of the exchange, including
26    /// tool results, which return to the model as user-role turns.
27    User,
28    /// The model's own turns.
29    Assistant,
30}
31
32impl Role {
33    /// The wire spelling — the same lowercase token serde reads and writes.
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            Self::System => "system",
37            Self::User => "user",
38            Self::Assistant => "assistant",
39        }
40    }
41}
42
43impl std::fmt::Display for Role {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49/// One piece of a message's content.
50///
51/// A message is a *list* of blocks rather than a string because a single
52/// assistant turn routinely mixes prose with tool calls, and a user turn
53/// mixes prose with tool results. Flattening that to text loses the pairing
54/// between a call and its result — which is one of the concrete ways a
55/// handoff drops context.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum ContentBlock {
60    /// Plain prose.
61    Text {
62        /// The prose itself.
63        text: String,
64    },
65
66    /// The model asking for a tool to be run.
67    ToolUse {
68        /// Correlates with the matching [`ContentBlock::ToolResult`].
69        id: String,
70        /// Which tool — a [`crate::tool::ToolSpec::name`].
71        name: String,
72        /// Arguments, shaped by the tool's declared schema.
73        input: serde_json::Value,
74    },
75
76    /// The answer to a [`ContentBlock::ToolUse`].
77    ToolResult {
78        /// The `id` of the [`ContentBlock::ToolUse`] this answers.
79        tool_use_id: String,
80        /// The tool's output — or its error text — as the model will read it.
81        content: String,
82        /// The tool failed and `content` is its error text.
83        #[serde(default)]
84        is_error: bool,
85    },
86}
87
88impl ContentBlock {
89    /// A prose block.
90    pub fn text(text: impl Into<String>) -> Self {
91        Self::Text { text: text.into() }
92    }
93
94    /// A tool call: run `name` with `input`; `id` pairs it with its result.
95    pub fn tool_use(
96        id: impl Into<String>,
97        name: impl Into<String>,
98        input: serde_json::Value,
99    ) -> Self {
100        Self::ToolUse {
101            id: id.into(),
102            name: name.into(),
103            input,
104        }
105    }
106
107    /// A successful tool result, answering the call identified by `tool_use_id`.
108    pub fn tool_result(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
109        Self::ToolResult {
110            tool_use_id: tool_use_id.into(),
111            content: content.into(),
112            is_error: false,
113        }
114    }
115
116    /// A failed tool call. Reported to the model rather than raised, so it can
117    /// adapt — a tool that errors is information, not the end of the turn.
118    pub fn tool_error(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
119        Self::ToolResult {
120            tool_use_id: tool_use_id.into(),
121            content: content.into(),
122            is_error: true,
123        }
124    }
125
126    /// The prose in this block, if it is prose.
127    pub fn as_text(&self) -> Option<&str> {
128        match self {
129            Self::Text { text } => Some(text),
130            _ => None,
131        }
132    }
133}
134
135/// One turn in a conversation.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct Message {
138    /// Who produced this turn.
139    pub role: Role,
140    /// The turn's blocks, in order.
141    pub content: Vec<ContentBlock>,
142}
143
144impl Message {
145    /// A turn with an explicit role and block list.
146    pub fn new(role: Role, content: Vec<ContentBlock>) -> Self {
147        Self { role, content }
148    }
149
150    /// A system turn holding one prose block.
151    pub fn system(text: impl Into<String>) -> Self {
152        Self::new(Role::System, vec![ContentBlock::text(text)])
153    }
154
155    /// A user turn holding one prose block — the common case.
156    pub fn user(text: impl Into<String>) -> Self {
157        Self::new(Role::User, vec![ContentBlock::text(text)])
158    }
159
160    /// An assistant turn holding one prose block.
161    pub fn assistant(text: impl Into<String>) -> Self {
162        Self::new(Role::Assistant, vec![ContentBlock::text(text)])
163    }
164
165    /// Concatenate this turn's prose, dropping tool blocks.
166    pub fn text(&self) -> String {
167        self.content
168            .iter()
169            .filter_map(ContentBlock::as_text)
170            .collect::<Vec<_>>()
171            .join("")
172    }
173
174    /// The tool calls this turn is asking for.
175    pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &str, &serde_json::Value)> {
176        self.content.iter().filter_map(|b| match b {
177            ContentBlock::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
178            _ => None,
179        })
180    }
181}
182
183/// A conversation: an ordered list of turns.
184///
185/// Carried between nodes as a [`Value::Json`] under this exact shape, so any
186/// consumer — Rust, Python, the report renderer — reads the same structure.
187#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
188#[serde(transparent)]
189pub struct Messages(pub Vec<Message>);
190
191impl Messages {
192    /// An empty conversation.
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Append a turn.
198    pub fn push(&mut self, message: Message) {
199        self.0.push(message);
200    }
201
202    /// How many turns so far.
203    pub fn len(&self) -> usize {
204        self.0.len()
205    }
206
207    /// Whether no turn has been added yet.
208    pub fn is_empty(&self) -> bool {
209        self.0.is_empty()
210    }
211
212    /// Iterate over the turns, oldest first.
213    pub fn iter(&self) -> std::slice::Iter<'_, Message> {
214        self.0.iter()
215    }
216
217    /// The most recent turn, if any.
218    pub fn last(&self) -> Option<&Message> {
219        self.0.last()
220    }
221
222    /// Encode as the `Value` that travels along an edge.
223    pub fn to_value(&self) -> Value {
224        Value::json(serde_json::to_value(self).unwrap_or(serde_json::Value::Null))
225    }
226
227    /// Read a conversation off an edge.
228    ///
229    /// Accepts three shapes, in decreasing specificity: a full message list,
230    /// a bare string (promoted to a single user turn), and a `Value::Text`
231    /// (likewise). The promotions exist so a plain prompt can feed a node
232    /// expecting a conversation without ceremony — the common first hop.
233    pub fn from_value(value: &Value) -> Result<Self> {
234        match value {
235            Value::Text(s) => Ok(Self(vec![Message::user(s.as_ref())])),
236            Value::Json(j) => {
237                if let Some(s) = j.as_str() {
238                    return Ok(Self(vec![Message::user(s)]));
239                }
240                serde_json::from_value((**j).clone()).map_err(|e| SomaError::SchemaMismatch {
241                    expected: "messages".into(),
242                    got: format!("json that is not a conversation: {e}"),
243                })
244            }
245            other => Err(SomaError::SchemaMismatch {
246                expected: "messages".into(),
247                got: other.type_name().to_string(),
248            }),
249        }
250    }
251}
252
253impl From<Vec<Message>> for Messages {
254    fn from(v: Vec<Message>) -> Self {
255        Self(v)
256    }
257}
258
259impl IntoIterator for Messages {
260    type Item = Message;
261    type IntoIter = std::vec::IntoIter<Message>;
262    fn into_iter(self) -> Self::IntoIter {
263        self.0.into_iter()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn roundtrips_through_a_value() {
273        let mut msgs = Messages::new();
274        msgs.push(Message::system("You are terse."));
275        msgs.push(Message::user("What is 2+2?"));
276        msgs.push(Message::new(
277            Role::Assistant,
278            vec![
279                ContentBlock::text("Let me compute that."),
280                ContentBlock::tool_use("t1", "calc", serde_json::json!({"expr": "2+2"})),
281            ],
282        ));
283        msgs.push(Message::new(
284            Role::User,
285            vec![ContentBlock::tool_result("t1", "4")],
286        ));
287
288        let decoded = Messages::from_value(&msgs.to_value()).unwrap();
289        assert_eq!(decoded, msgs);
290    }
291
292    /// A bare prompt should feed a conversation-shaped node without the
293    /// caller having to build a message list first.
294    #[test]
295    fn promotes_a_bare_string_to_a_user_turn() {
296        for v in [
297            Value::text("Summarize this."),
298            Value::json(serde_json::json!("Summarize this.")),
299        ] {
300            let msgs = Messages::from_value(&v).unwrap();
301            assert_eq!(msgs.len(), 1);
302            assert_eq!(msgs.0[0].role, Role::User);
303            assert_eq!(msgs.0[0].text(), "Summarize this.");
304        }
305    }
306
307    #[test]
308    fn rejects_values_that_are_not_conversations() {
309        let err = Messages::from_value(&Value::tensor(vec![1.0], vec![1])).unwrap_err();
310        assert!(err.to_string().contains("messages"), "{err}");
311
312        let err = Messages::from_value(&Value::json(serde_json::json!({"a": 1}))).unwrap_err();
313        assert!(err.to_string().contains("messages"), "{err}");
314    }
315
316    #[test]
317    fn text_concatenates_prose_and_skips_tool_blocks() {
318        let m = Message::new(
319            Role::Assistant,
320            vec![
321                ContentBlock::text("a"),
322                ContentBlock::tool_use("t", "n", serde_json::json!({})),
323                ContentBlock::text("b"),
324            ],
325        );
326        assert_eq!(m.text(), "ab");
327        assert_eq!(m.tool_uses().count(), 1);
328    }
329
330    #[test]
331    fn tool_errors_are_marked() {
332        let ok = ContentBlock::tool_result("t", "fine");
333        let bad = ContentBlock::tool_error("t", "boom");
334        assert!(matches!(
335            ok,
336            ContentBlock::ToolResult {
337                is_error: false,
338                ..
339            }
340        ));
341        assert!(matches!(
342            bad,
343            ContentBlock::ToolResult { is_error: true, .. }
344        ));
345    }
346}