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}