Skip to main content

gglib_core/domain/agent/
messages.rs

1//! [`AgentMessage`] — A single message in the agent conversation.
2//!
3//! This module contains pure domain structs and enums.  All custom
4//! [`Serialize`] / [`Deserialize`] implementations live in the sibling
5//! [`super::messages_serde`] module to keep domain types free of
6//! serialisation noise.
7
8use serde::{Deserialize, Serialize};
9
10use super::tool_types::ToolCall;
11
12/// Content carried by an [`AgentMessage::Assistant`] turn.
13///
14/// A flat struct with optional `text` and a (possibly empty) `tool_calls` vec.
15/// At the wire level, at least one of the two fields must be present — the
16/// hand-rolled [`Deserialize`] impl (in [`super::messages_serde`]) enforces
17/// this.
18///
19/// # Serde
20///
21/// Serializes/deserializes as a flat map so it can be `#[serde(flatten)]`-ed
22/// directly into the parent [`AgentMessage`] object:
23///
24/// | State | JSON fields |
25/// |-------|-------------|
26/// | text only | `"content": "..."` |
27/// | tool calls only | `"tool_calls": [...]` |
28/// | both | `"content": "...", "tool_calls": [...]` |
29///
30/// Custom `Serialize` and `Deserialize` impls are in
31/// [`super::messages_serde`].
32#[derive(Debug, Clone)]
33#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
34pub struct AssistantContent {
35    /// Optional text content from the model.  `None` when the model produced
36    /// only tool calls with no text preamble.
37    ///
38    /// Both annotations below restate what [`super::messages_serde`]'s
39    /// hand-written impls do, because ts-rs reads *fields* and cannot see a
40    /// manual `Serialize`. Without them the binding claims a `text` key that
41    /// no payload has ever carried. The table above is the contract.
42    #[cfg_attr(feature = "ts-bindings", ts(rename = "content", optional))]
43    pub text: Option<String>,
44    /// Tool calls requested by the model.  Empty when the model produced a
45    /// text-only response (final answer).
46    ///
47    /// `as Option<…>` because the impl omits the key entirely for an empty
48    /// vec, which is a state a bare `Vec` cannot express in TypeScript.
49    #[cfg_attr(feature = "ts-bindings", ts(as = "Option<Vec<ToolCall>>", optional))]
50    pub tool_calls: Vec<ToolCall>,
51}
52
53impl AssistantContent {
54    /// Consume `self` and return a new value with `calls` as the tool-call
55    /// list, preserving any existing text content.
56    #[must_use]
57    pub fn with_replaced_tool_calls(self, calls: Vec<ToolCall>) -> Self {
58        Self {
59            tool_calls: calls,
60            ..self
61        }
62    }
63}
64
65/// A single message in the agent conversation.
66///
67/// The closed enum prevents invalid states that a flat struct with `role: String`
68/// would allow (e.g. a `User` message carrying `tool_calls`, or a `Tool` message
69/// without a `tool_call_id`).
70///
71/// # Wire format
72///
73/// `#[serde(tag = "role", rename_all = "lowercase")]` produces JSON identical to
74/// the TypeScript `ChatMessage` interface in the frontend:
75///
76/// ```json
77/// { "role": "user", "content": "What files are in the project?" }
78/// { "role": "assistant", "content": null, "tool_calls": [...] }
79/// { "role": "tool", "tool_call_id": "call_abc", "content": "src/\nlib/" }
80/// ```
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(tag = "role", rename_all = "lowercase")]
83#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
84pub enum AgentMessage {
85    /// A system-level instruction that sets the model's persona and constraints.
86    System {
87        /// Instruction text.
88        content: String,
89    },
90
91    /// A message from the human user.
92    User {
93        /// Message text.
94        content: String,
95    },
96
97    /// A response from the assistant model.
98    ///
99    /// `content` always carries either text, tool calls, or both — the
100    /// vacuous all-`None` state of the previous `Option<String>` +
101    /// `Option<Vec<ToolCall>>` representation is impossible to construct.
102    Assistant {
103        /// Content of the assistant turn.
104        #[serde(flatten)]
105        content: AssistantContent,
106    },
107
108    /// The result of a tool call, to be sent back to the model.
109    Tool {
110        /// Must match the [`ToolCall::id`] from the preceding `Assistant` message.
111        tool_call_id: String,
112
113        /// Serialised output of the tool (or error description if it failed).
114        content: String,
115    },
116}
117
118impl AgentMessage {
119    /// Estimate the Unicode scalar-value count of this message.
120    ///
121    /// Uses `str::chars().count()` rather than [`str::len`] (byte count) so
122    /// that multi-byte characters are counted as one unit, matching how LLMs
123    /// typically measure context length.
124    ///
125    /// # Performance
126    ///
127    /// This is an **O(n)** scan — it iterates over every Unicode scalar value
128    /// in every `str` field of the message. Avoid calling it inside tight or
129    /// nested loops. For repeated measurements over the same message set,
130    /// accumulate the total once and update it incrementally (the agent loop
131    /// does exactly this via its `running_chars` counter).
132    pub fn char_count(&self) -> usize {
133        match self {
134            Self::System { content } | Self::User { content } => content.chars().count(),
135            Self::Assistant { content } => {
136                content.text.as_ref().map_or(0, |s| s.chars().count())
137                    + content
138                        .tool_calls
139                        .iter()
140                        .map(|c| {
141                            // Include `id` so the context-budget estimate
142                            // matches what llama-server actually tokenises
143                            // (a typical id like "call_abc123" is ~15 chars).
144                            c.id.chars().count()
145                                + c.name.chars().count()
146                                + c.arguments.to_string().chars().count()
147                        })
148                        .sum::<usize>()
149            }
150            Self::Tool {
151                tool_call_id,
152                content,
153            } => tool_call_id.chars().count() + content.chars().count(),
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn serde_tag_matches_wire_format() {
164        let msg = AgentMessage::Tool {
165            tool_call_id: "call_1".into(),
166            content: "ok".into(),
167        };
168        let json = serde_json::to_value(&msg).unwrap();
169        assert_eq!(json["role"], "tool");
170        assert_eq!(json["tool_call_id"], "call_1");
171    }
172
173    #[test]
174    fn assistant_content_only_omits_tool_calls() {
175        let msg = AgentMessage::Assistant {
176            content: AssistantContent {
177                text: Some("hi".into()),
178                tool_calls: vec![],
179            },
180        };
181        let json = serde_json::to_value(&msg).unwrap();
182        assert_eq!(json["role"], "assistant");
183        assert_eq!(json["content"], "hi");
184        assert!(json.get("tool_calls").is_none());
185    }
186
187    #[test]
188    fn assistant_tool_calls_only_omits_content() {
189        use serde_json::json;
190        let msg = AgentMessage::Assistant {
191            content: AssistantContent {
192                text: None,
193                tool_calls: vec![ToolCall {
194                    id: "c1".into(),
195                    name: "search".into(),
196                    arguments: json!({}),
197                }],
198            },
199        };
200        let json_val = serde_json::to_value(&msg).unwrap();
201        assert_eq!(json_val["role"], "assistant");
202        assert!(json_val.get("content").is_none());
203        assert!(json_val["tool_calls"].is_array());
204    }
205
206    /// Verify that the custom Serde deserializer reconstructs
207    /// [`AssistantContent`] correctly on a round-trip when both text and
208    /// tool calls are present.
209    ///
210    /// Some LLMs (e.g. models with parallel function calling) emit a non-empty
211    /// `content` string alongside `tool_calls` in the same assistant message.
212    /// The round-trip must preserve both fields exactly.
213    #[test]
214    fn assistant_both_round_trips() {
215        use serde_json::json;
216
217        let original = AgentMessage::Assistant {
218            content: AssistantContent {
219                text: Some("thinking out loud".into()),
220                tool_calls: vec![
221                    ToolCall {
222                        id: "c1".into(),
223                        name: "web_search".into(),
224                        arguments: json!({ "query": "rust async" }),
225                    },
226                    ToolCall {
227                        id: "c2".into(),
228                        name: "read_file".into(),
229                        arguments: json!({ "path": "/tmp/x" }),
230                    },
231                ],
232            },
233        };
234
235        // Serialise -> deserialise.
236        let json_val = serde_json::to_value(&original).unwrap();
237        assert_eq!(json_val["role"], "assistant");
238        assert_eq!(
239            json_val["content"], "thinking out loud",
240            "content must be present"
241        );
242        assert_eq!(
243            json_val["tool_calls"].as_array().unwrap().len(),
244            2,
245            "tool_calls must be present with 2 entries"
246        );
247
248        // Round-trip: deserialise back from the serialised value.
249        let reconstructed: AgentMessage = serde_json::from_value(json_val).unwrap();
250        if let AgentMessage::Assistant { content } = reconstructed {
251            assert_eq!(content.text.as_deref(), Some("thinking out loud"));
252            assert_eq!(content.tool_calls.len(), 2);
253            assert_eq!(content.tool_calls[0].id, "c1");
254            assert_eq!(content.tool_calls[1].name, "read_file");
255        } else {
256            panic!("expected AgentMessage::Assistant");
257        }
258    }
259
260    #[test]
261    fn with_replaced_tool_calls_preserves_text() {
262        use serde_json::json;
263        let original = AssistantContent {
264            text: Some("hello".into()),
265            tool_calls: vec![],
266        };
267        let calls = vec![ToolCall {
268            id: "c1".into(),
269            name: "search".into(),
270            arguments: json!({}),
271        }];
272        let result = original.with_replaced_tool_calls(calls);
273        assert_eq!(result.text.as_deref(), Some("hello"));
274        assert_eq!(result.tool_calls.len(), 1);
275        assert_eq!(result.tool_calls[0].id, "c1");
276    }
277
278    #[test]
279    fn with_replaced_tool_calls_replaces_existing() {
280        use serde_json::json;
281        let original = AssistantContent {
282            text: Some("thinking".into()),
283            tool_calls: vec![ToolCall {
284                id: "old".into(),
285                name: "old_tool".into(),
286                arguments: json!({}),
287            }],
288        };
289        let new_calls = vec![ToolCall {
290            id: "new".into(),
291            name: "new_tool".into(),
292            arguments: json!({"key": "val"}),
293        }];
294        let result = original.with_replaced_tool_calls(new_calls);
295        assert_eq!(result.text.as_deref(), Some("thinking"));
296        assert_eq!(result.tool_calls.len(), 1);
297        assert_eq!(result.tool_calls[0].name, "new_tool");
298    }
299
300    #[test]
301    fn with_replaced_tool_calls_no_text() {
302        use serde_json::json;
303        let original = AssistantContent {
304            text: None,
305            tool_calls: vec![ToolCall {
306                id: "old".into(),
307                name: "old".into(),
308                arguments: json!({}),
309            }],
310        };
311        let new_calls = vec![ToolCall {
312            id: "new".into(),
313            name: "new".into(),
314            arguments: json!({}),
315        }];
316        let result = original.with_replaced_tool_calls(new_calls);
317        assert!(result.text.is_none());
318        assert_eq!(result.tool_calls.len(), 1);
319        assert_eq!(result.tool_calls[0].id, "new");
320    }
321}