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