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}