1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::types::{ChatMessage, ImageAttachment, Message, MessageRole, ToolCallMessage};
6
7use crate::types::SessionId;
8
9#[derive(Clone, Debug, Default, Serialize, Deserialize)]
10pub struct AgentSession {
11 id: Option<SessionId>,
12 chat_messages: Vec<ChatMessage>,
15 always_allowed_actions: HashSet<String>,
16 pub total_tool_calls: usize,
19 pub nudge_count: usize,
23 pub turn_tool_calls: usize,
27}
28
29impl AgentSession {
30 pub fn new(id: SessionId) -> Self {
31 Self {
32 id: Some(id),
33 chat_messages: Vec::new(),
34 always_allowed_actions: HashSet::new(),
35 total_tool_calls: 0,
36 nudge_count: 0,
37 turn_tool_calls: 0,
38 }
39 }
40
41 pub fn id(&self) -> Option<SessionId> {
42 self.id.clone()
43 }
44
45 pub fn simple_messages(&self) -> Vec<Message> {
49 self.chat_messages
50 .iter()
51 .filter_map(|cm| match cm {
52 ChatMessage::Assistant {
53 content: None, ..
54 } => None,
55 ChatMessage::Assistant {
56 content: Some(c),
57 tool_calls: Some(tc),
58 ..
59 } if c.is_empty() && !tc.is_empty() => None,
60 _ => Some(Message::from(cm)),
61 })
62 .collect()
63 }
64
65 pub fn chat_messages(&self) -> &[ChatMessage] {
66 &self.chat_messages
67 }
68
69 pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
71 &mut self.chat_messages
72 }
73
74 pub fn is_action_allowed(&self, action_key: &str) -> bool {
75 self.always_allowed_actions.contains(action_key)
76 }
77
78 pub fn allow_action(&mut self, action_key: impl Into<String>) {
79 self.always_allowed_actions.insert(action_key.into());
80 }
81
82 pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
83 let content = content.into();
84 let chat_msg = match role {
85 MessageRole::System => ChatMessage::system(content),
86 MessageRole::User => ChatMessage::user(content),
87 MessageRole::Assistant => ChatMessage::assistant(content),
88 MessageRole::Tool => ChatMessage::tool(String::new(), content),
89 };
90 self.chat_messages.push(chat_msg);
91 }
92
93 pub fn push_assistant_with_reasoning(
97 &mut self,
98 content: impl Into<String>,
99 reasoning: impl Into<String>,
100 ) {
101 self.chat_messages
102 .push(ChatMessage::assistant_with_reasoning(content, reasoning));
103 }
104
105 pub fn push_user_message_with_images(
106 &mut self,
107 content: impl Into<String>,
108 images: Vec<ImageAttachment>,
109 ) {
110 self.chat_messages
111 .push(ChatMessage::user_with_images(content, images));
112 }
113
114 pub fn push_assistant_tool_call(
115 &mut self,
116 tool_call_id: &str,
117 tool_name: &str,
118 arguments_json: &str,
119 ) {
120 self.chat_messages.push(ChatMessage::assistant_tool_call(
121 tool_call_id,
122 tool_name,
123 arguments_json,
124 ));
125 }
126
127 pub fn push_assistant_tool_calls(
128 &mut self,
129 tool_calls: &[(String, String, String)],
130 reasoning: Option<String>,
131 ) {
132 let calls: Vec<ToolCallMessage> = tool_calls
133 .iter()
134 .map(|(id, name, args)| ToolCallMessage {
135 id: id.clone(),
136 name: name.clone(),
137 arguments: args.clone(),
138 })
139 .collect();
140 self.chat_messages.push(ChatMessage::Assistant {
141 content: None,
142 reasoning_content: reasoning,
143 tool_calls: Some(calls),
144 });
145 }
146
147 pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
148 self.chat_messages
149 .push(ChatMessage::tool(tool_call_id, content));
150 }
151
152 pub fn remove_ephemeral_messages(&mut self) {
156 let before = self.chat_messages.len();
157 self.chat_messages.retain(|m| !m.is_ephemeral());
158 let removed = before - self.chat_messages.len();
159 if removed > 0 {
160 tracing::debug!(removed, remaining = self.chat_messages.len(), "ephemeral messages cleaned up");
161 }
162 }
163
164 pub fn turn_count(&self) -> usize {
167 self.chat_messages
168 .iter()
169 .filter(|m| matches!(m, ChatMessage::User { .. }))
170 .count()
171 }
172
173 pub fn trim_oldest_turns(&mut self, max_turns: usize) {
176 let current_turns = self.turn_count();
177 if current_turns <= max_turns {
178 return;
179 }
180 let turns_to_remove = current_turns - max_turns;
181
182 let user_positions: Vec<usize> = self
184 .chat_messages
185 .iter()
186 .enumerate()
187 .filter_map(|(i, m)| if matches!(m, ChatMessage::User { .. }) { Some(i) } else { None })
188 .collect();
189
190 if user_positions.len() <= turns_to_remove {
191 return;
192 }
193
194 let system_prefix = self
196 .chat_messages
197 .iter()
198 .take_while(|m| matches!(m, ChatMessage::System { .. }))
199 .count();
200
201 let drain_end = user_positions[turns_to_remove];
203 if system_prefix >= drain_end {
204 return; }
206
207 self.chat_messages.drain(system_prefix..drain_end);
208 }
209
210 pub fn pop_last_message(&mut self) {
213 self.chat_messages.pop();
214 }
215
216 pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
217 let assistant_idx = self.chat_messages.iter().rposition(|m| {
218 matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty())
219 });
220
221 let Some(assistant_idx) = assistant_idx else {
222 return;
223 };
224
225 let ChatMessage::Assistant { tool_calls: Some(tc), .. } = &self.chat_messages[assistant_idx] else {
226 return;
227 };
228
229 let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
230
231 let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
232 .iter()
233 .filter_map(|m| match m {
234 ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
235 _ => None,
236 })
237 .collect();
238
239 for id in &all_ids {
240 if !answered_ids.iter().any(|a| a == id) {
241 self.push_tool_result(id, error_summary);
242 }
243 }
244 }
245
246 pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
251 validate_message_sequence(&messages)?;
252 self.total_tool_calls = messages
255 .iter()
256 .filter_map(|m| match m {
257 ChatMessage::Assistant {
258 tool_calls: Some(tc), ..
259 } => Some(tc.len()),
260 _ => None,
261 })
262 .sum();
263 self.chat_messages = messages;
264 Ok(())
265 }
266}
267
268pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
276 let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
277
278 for (i, msg) in messages.iter().enumerate() {
279 match msg {
280 ChatMessage::Tool { tool_call_id, .. } => {
281 if pending_tool_call_ids.is_empty() {
282 return Err(format!(
283 "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
284 i, tool_call_id
285 ));
286 }
287 if !pending_tool_call_ids.remove(tool_call_id) {
289 return Err(format!(
290 "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
291 i, tool_call_id
292 ));
293 }
294 }
295 ChatMessage::Assistant {
296 tool_calls: Some(tc), ..
297 } => {
298 if !pending_tool_call_ids.is_empty() {
300 return Err(format!(
301 "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
302 i, pending_tool_call_ids
303 ));
304 }
305 pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
306 }
307 _ => {}
308 }
309 }
310
311 if !pending_tool_call_ids.is_empty() {
313 return Err(format!(
314 "message sequence ends with unanswered tool calls: {:?}",
315 pending_tool_call_ids
316 ));
317 }
318
319 Ok(())
320}
321
322#[cfg(test)]
323fn make_session() -> AgentSession {
324 AgentSession::new(SessionId::new(1))
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_turn_count_empty() {
333 let s = make_session();
334 assert_eq!(s.turn_count(), 0);
335 }
336
337 #[test]
338 fn test_turn_count_with_system_and_user() {
339 let mut s = make_session();
340 s.push_message(MessageRole::System, "system");
341 assert_eq!(s.turn_count(), 0);
342 s.push_message(MessageRole::User, "hello");
343 assert_eq!(s.turn_count(), 1);
344 s.push_message(MessageRole::Assistant, "hi");
345 assert_eq!(s.turn_count(), 1);
346 s.push_message(MessageRole::User, "bye");
347 assert_eq!(s.turn_count(), 2);
348 }
349
350 #[test]
351 fn test_turn_count_with_tool_calls() {
352 let mut s = make_session();
353 s.push_message(MessageRole::User, "do something");
354 s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None);
355 s.push_tool_result("id1", "result");
356 s.push_message(MessageRole::Assistant, "done");
357 assert_eq!(s.turn_count(), 1);
359 }
360
361 #[test]
362 fn test_trim_oldest_turns_noop() {
363 let mut s = make_session();
364 s.push_message(MessageRole::User, "hello");
365 s.push_message(MessageRole::Assistant, "hi");
366 s.trim_oldest_turns(5);
367 assert_eq!(s.turn_count(), 1);
368 assert_eq!(s.chat_messages().len(), 2);
369 }
370
371 #[test]
372 fn test_trim_oldest_turns_removes_old() {
373 let mut s = make_session();
374 s.push_message(MessageRole::System, "sys");
375 s.push_message(MessageRole::User, "u1");
377 s.push_message(MessageRole::Assistant, "a1");
378 s.push_message(MessageRole::User, "u2");
380 s.push_message(MessageRole::Assistant, "a2");
381 s.push_message(MessageRole::User, "u3");
383 s.push_message(MessageRole::Assistant, "a3");
384
385 s.trim_oldest_turns(2);
386 assert_eq!(s.turn_count(), 2);
387 assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
389 assert!(matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2"));
391 }
392
393 #[test]
394 fn test_trim_oldest_turns_with_tool_calls() {
395 let mut s = make_session();
396 s.push_message(MessageRole::User, "u1");
398 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
399 s.push_tool_result("id1", "r1");
400 s.push_message(MessageRole::Assistant, "a1");
401 s.push_message(MessageRole::User, "u2");
403 s.push_message(MessageRole::Assistant, "a2");
404
405 let msg_before = s.simple_messages().len();
406 let chat_before = s.chat_messages().len();
407 s.trim_oldest_turns(1);
408 assert_eq!(s.turn_count(), 1);
409 assert_eq!(s.chat_messages().len(), chat_before - 4);
411 assert_eq!(s.simple_messages().len(), msg_before - 3);
413 }
414
415 #[test]
416 fn test_pop_last_message_text() {
417 let mut s = make_session();
418 s.push_message(MessageRole::User, "hello");
419 s.push_message(MessageRole::Assistant, "hi");
420 assert_eq!(s.chat_messages().len(), 2);
421 s.pop_last_message();
422 assert_eq!(s.chat_messages().len(), 1);
423 assert_eq!(s.simple_messages().len(), 1);
424 }
425
426 #[test]
427 fn test_pop_last_message_tool_calls_only() {
428 let mut s = make_session();
429 s.push_message(MessageRole::User, "do it");
430 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
431 assert_eq!(s.chat_messages().len(), 2);
432 assert_eq!(s.simple_messages().len(), 1); s.pop_last_message();
434 assert_eq!(s.chat_messages().len(), 1);
435 assert_eq!(s.simple_messages().len(), 1); }
437
438 #[test]
439 fn test_pop_last_message_empty_session() {
440 let mut s = make_session();
441 s.pop_last_message(); assert_eq!(s.chat_messages().len(), 0);
443 }
444}
445
446#[cfg(test)]
447mod validate_tests {
448 use super::*;
449
450 #[test]
451 fn test_valid_simple_sequence() {
452 let msgs = vec![
453 ChatMessage::user("hello"),
454 ChatMessage::assistant("hi"),
455 ];
456 assert!(validate_message_sequence(&msgs).is_ok());
457 }
458
459 #[test]
460 fn test_valid_tool_call_sequence() {
461 let msgs = vec![
462 ChatMessage::user("run command"),
463 ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
464 ChatMessage::tool("call_1", "file1 file2"),
465 ChatMessage::assistant("done"),
466 ];
467 assert!(validate_message_sequence(&msgs).is_ok());
468 }
469
470 #[test]
471 fn test_valid_multi_tool_call_sequence() {
472 let msgs = vec![
473 ChatMessage::user("run commands"),
474 ChatMessage::Assistant {
475 content: None,
476 reasoning_content: None,
477 tool_calls: Some(vec![
478 crate::types::ToolCallMessage {
479 id: "call_1".into(),
480 name: "bash".into(),
481 arguments: "{}".into(),
482 },
483 crate::types::ToolCallMessage {
484 id: "call_2".into(),
485 name: "read".into(),
486 arguments: "{}".into(),
487 },
488 ]),
489 },
490 ChatMessage::tool("call_1", "result1"),
491 ChatMessage::tool("call_2", "result2"),
492 ChatMessage::assistant("done"),
493 ];
494 assert!(validate_message_sequence(&msgs).is_ok());
495 }
496
497 #[test]
498 fn test_orphaned_tool_result() {
499 let msgs = vec![
500 ChatMessage::user("hello"),
501 ChatMessage::tool("call_1", "orphaned result"),
502 ];
503 let err = validate_message_sequence(&msgs).unwrap_err();
504 assert!(err.contains("no preceding tool_call"));
505 }
506
507 #[test]
508 fn test_mismatched_tool_call_id() {
509 let msgs = vec![
510 ChatMessage::user("run"),
511 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
512 ChatMessage::tool("call_2", "wrong id"),
513 ];
514 let err = validate_message_sequence(&msgs).unwrap_err();
515 assert!(err.contains("does not match"));
516 }
517
518 #[test]
519 fn test_set_chat_messages_valid() {
520 let mut s = make_session();
521 let msgs = vec![
522 ChatMessage::user("hello"),
523 ChatMessage::assistant("hi"),
524 ];
525 assert!(s.set_chat_messages(msgs.clone()).is_ok());
526 assert_eq!(s.chat_messages().len(), 2);
527 }
528
529 #[test]
530 fn test_set_chat_messages_invalid() {
531 let mut s = make_session();
532 let msgs = vec![
533 ChatMessage::tool("call_1", "orphaned"),
534 ];
535 assert!(s.set_chat_messages(msgs).is_err());
536 }
537}