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 pub reasoning_only_strikes: usize,
32 pub empty_response_strikes: usize,
37}
38
39impl AgentSession {
40 pub fn new(id: SessionId) -> Self {
41 Self {
42 id: Some(id),
43 chat_messages: Vec::new(),
44 always_allowed_actions: HashSet::new(),
45 total_tool_calls: 0,
46 nudge_count: 0,
47 turn_tool_calls: 0,
48 reasoning_only_strikes: 0,
49 empty_response_strikes: 0,
50 }
51 }
52
53 pub fn id(&self) -> Option<SessionId> {
54 self.id.clone()
55 }
56
57 pub fn simple_messages(&self) -> Vec<Message> {
61 self.chat_messages
62 .iter()
63 .filter_map(|cm| match cm {
64 ChatMessage::Assistant { content: None, .. } => None,
65 ChatMessage::Assistant {
66 content: Some(c),
67 tool_calls: Some(tc),
68 ..
69 } if c.is_empty() && !tc.is_empty() => None,
70 _ => Some(Message::from(cm)),
71 })
72 .collect()
73 }
74
75 pub fn chat_messages(&self) -> &[ChatMessage] {
76 &self.chat_messages
77 }
78
79 pub fn chat_messages_mut(&mut self) -> &mut Vec<ChatMessage> {
81 &mut self.chat_messages
82 }
83
84 pub fn is_action_allowed(&self, action_key: &str) -> bool {
85 self.always_allowed_actions.contains(action_key)
86 }
87
88 pub fn allow_action(&mut self, action_key: impl Into<String>) {
89 self.always_allowed_actions.insert(action_key.into());
90 }
91
92 pub fn push_message(&mut self, role: MessageRole, content: impl Into<String>) {
93 let content = content.into();
94 let chat_msg = match role {
95 MessageRole::System => ChatMessage::system(content),
96 MessageRole::User => ChatMessage::user(content),
97 MessageRole::Assistant => ChatMessage::assistant(content),
98 MessageRole::Tool => ChatMessage::tool(String::new(), content),
99 };
100 self.chat_messages.push(chat_msg);
101 }
102
103 pub fn push_assistant_with_reasoning(
107 &mut self,
108 content: impl Into<String>,
109 reasoning: impl Into<String>,
110 ) {
111 self.chat_messages
112 .push(ChatMessage::assistant_with_reasoning(content, reasoning));
113 }
114
115 pub fn push_user_message_with_images(
116 &mut self,
117 content: impl Into<String>,
118 images: Vec<ImageAttachment>,
119 ) {
120 self.chat_messages
121 .push(ChatMessage::user_with_images(content, images));
122 }
123
124 pub fn push_assistant_tool_call(
125 &mut self,
126 tool_call_id: &str,
127 tool_name: &str,
128 arguments_json: &str,
129 ) {
130 self.chat_messages.push(ChatMessage::assistant_tool_call(
131 tool_call_id,
132 tool_name,
133 arguments_json,
134 ));
135 }
136
137 pub fn push_assistant_tool_calls(
138 &mut self,
139 tool_calls: &[(String, String, String)],
140 reasoning: Option<String>,
141 ) {
142 let calls: Vec<ToolCallMessage> = tool_calls
143 .iter()
144 .map(|(id, name, args)| ToolCallMessage {
145 id: id.clone(),
146 name: name.clone(),
147 arguments: args.clone(),
148 })
149 .collect();
150 self.chat_messages.push(ChatMessage::Assistant {
151 content: None,
152 reasoning_content: reasoning,
153 tool_calls: Some(calls),
154 });
155 }
156
157 pub fn push_tool_result(&mut self, tool_call_id: &str, content: impl Into<String>) {
158 self.chat_messages
159 .push(ChatMessage::tool(tool_call_id, content));
160 }
161
162 pub fn remove_ephemeral_messages(&mut self) {
166 let before = self.chat_messages.len();
167 self.chat_messages.retain(|m| !m.is_ephemeral());
168 let removed = before - self.chat_messages.len();
169 if removed > 0 {
170 tracing::debug!(
171 removed,
172 remaining = self.chat_messages.len(),
173 "ephemeral messages cleaned up"
174 );
175 }
176 }
177
178 pub fn turn_count(&self) -> usize {
181 self.chat_messages
182 .iter()
183 .filter(|m| matches!(m, ChatMessage::User { .. }))
184 .count()
185 }
186
187 pub fn trim_oldest_turns(&mut self, max_turns: usize) {
190 let current_turns = self.turn_count();
191 if current_turns <= max_turns {
192 return;
193 }
194 let turns_to_remove = current_turns - max_turns;
195
196 let user_positions: Vec<usize> = self
198 .chat_messages
199 .iter()
200 .enumerate()
201 .filter_map(|(i, m)| {
202 if matches!(m, ChatMessage::User { .. }) {
203 Some(i)
204 } else {
205 None
206 }
207 })
208 .collect();
209
210 if user_positions.len() <= turns_to_remove {
211 return;
212 }
213
214 let system_prefix = self
216 .chat_messages
217 .iter()
218 .take_while(|m| matches!(m, ChatMessage::System { .. }))
219 .count();
220
221 let drain_end = user_positions[turns_to_remove];
223 if system_prefix >= drain_end {
224 return; }
226
227 self.chat_messages.drain(system_prefix..drain_end);
228 }
229
230 pub fn pop_last_message(&mut self) {
233 self.chat_messages.pop();
234 }
235
236 pub fn close_dangling_tool_calls(&mut self, error_summary: &str) {
237 let assistant_idx = self.chat_messages.iter().rposition(
238 |m| matches!(m, ChatMessage::Assistant { tool_calls: Some(tc), .. } if !tc.is_empty()),
239 );
240
241 let Some(assistant_idx) = assistant_idx else {
242 return;
243 };
244
245 let ChatMessage::Assistant {
246 tool_calls: Some(tc),
247 ..
248 } = &self.chat_messages[assistant_idx]
249 else {
250 return;
251 };
252
253 let all_ids: Vec<String> = tc.iter().map(|t| t.id.clone()).collect();
254
255 let answered_ids: Vec<String> = self.chat_messages[assistant_idx + 1..]
256 .iter()
257 .filter_map(|m| match m {
258 ChatMessage::Tool { tool_call_id, .. } => Some(tool_call_id.clone()),
259 _ => None,
260 })
261 .collect();
262
263 for id in &all_ids {
264 if !answered_ids.iter().any(|a| a == id) {
265 self.push_tool_result(id, error_summary);
266 }
267 }
268 }
269
270 pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
275 validate_message_sequence(&messages)?;
276 self.total_tool_calls = messages
279 .iter()
280 .filter_map(|m| match m {
281 ChatMessage::Assistant {
282 tool_calls: Some(tc),
283 ..
284 } => Some(tc.len()),
285 _ => None,
286 })
287 .sum();
288 self.chat_messages = messages;
289 Ok(())
290 }
291}
292
293pub fn validate_message_sequence(messages: &[ChatMessage]) -> Result<(), String> {
301 let mut pending_tool_call_ids: HashSet<String> = HashSet::new();
302
303 for (i, msg) in messages.iter().enumerate() {
304 match msg {
305 ChatMessage::Tool { tool_call_id, .. } => {
306 if pending_tool_call_ids.is_empty() {
307 return Err(format!(
308 "message[{}]: Tool message with call_id '{}' has no preceding tool_call",
309 i, tool_call_id
310 ));
311 }
312 if !pending_tool_call_ids.remove(tool_call_id) {
314 return Err(format!(
315 "message[{}]: Tool message with call_id '{}' does not match any pending tool_call (already answered or unknown)",
316 i, tool_call_id
317 ));
318 }
319 }
320 ChatMessage::Assistant {
321 tool_calls: Some(tc),
322 ..
323 } => {
324 if !pending_tool_call_ids.is_empty() {
326 return Err(format!(
327 "message[{}]: Assistant message with new tool_calls appears before pending calls were answered: {:?}",
328 i, pending_tool_call_ids
329 ));
330 }
331 pending_tool_call_ids = tc.iter().map(|t| t.id.clone()).collect();
332 }
333 _ => {}
334 }
335 }
336
337 if !pending_tool_call_ids.is_empty() {
339 return Err(format!(
340 "message sequence ends with unanswered tool calls: {:?}",
341 pending_tool_call_ids
342 ));
343 }
344
345 Ok(())
346}
347
348#[cfg(test)]
349fn make_session() -> AgentSession {
350 AgentSession::new(SessionId::new(1))
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn test_turn_count_empty() {
359 let s = make_session();
360 assert_eq!(s.turn_count(), 0);
361 }
362
363 #[test]
364 fn test_turn_count_with_system_and_user() {
365 let mut s = make_session();
366 s.push_message(MessageRole::System, "system");
367 assert_eq!(s.turn_count(), 0);
368 s.push_message(MessageRole::User, "hello");
369 assert_eq!(s.turn_count(), 1);
370 s.push_message(MessageRole::Assistant, "hi");
371 assert_eq!(s.turn_count(), 1);
372 s.push_message(MessageRole::User, "bye");
373 assert_eq!(s.turn_count(), 2);
374 }
375
376 #[test]
377 fn test_turn_count_with_tool_calls() {
378 let mut s = make_session();
379 s.push_message(MessageRole::User, "do something");
380 s.push_assistant_tool_calls(&[("id1".into(), "tool".into(), "{}".into())], None);
381 s.push_tool_result("id1", "result");
382 s.push_message(MessageRole::Assistant, "done");
383 assert_eq!(s.turn_count(), 1);
385 }
386
387 #[test]
388 fn test_trim_oldest_turns_noop() {
389 let mut s = make_session();
390 s.push_message(MessageRole::User, "hello");
391 s.push_message(MessageRole::Assistant, "hi");
392 s.trim_oldest_turns(5);
393 assert_eq!(s.turn_count(), 1);
394 assert_eq!(s.chat_messages().len(), 2);
395 }
396
397 #[test]
398 fn test_trim_oldest_turns_removes_old() {
399 let mut s = make_session();
400 s.push_message(MessageRole::System, "sys");
401 s.push_message(MessageRole::User, "u1");
403 s.push_message(MessageRole::Assistant, "a1");
404 s.push_message(MessageRole::User, "u2");
406 s.push_message(MessageRole::Assistant, "a2");
407 s.push_message(MessageRole::User, "u3");
409 s.push_message(MessageRole::Assistant, "a3");
410
411 s.trim_oldest_turns(2);
412 assert_eq!(s.turn_count(), 2);
413 assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
415 assert!(
417 matches!(s.chat_messages()[1], ChatMessage::User { ref content, .. } if content == "u2")
418 );
419 }
420
421 #[test]
422 fn test_trim_oldest_turns_with_tool_calls() {
423 let mut s = make_session();
424 s.push_message(MessageRole::User, "u1");
426 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
427 s.push_tool_result("id1", "r1");
428 s.push_message(MessageRole::Assistant, "a1");
429 s.push_message(MessageRole::User, "u2");
431 s.push_message(MessageRole::Assistant, "a2");
432
433 let msg_before = s.simple_messages().len();
434 let chat_before = s.chat_messages().len();
435 s.trim_oldest_turns(1);
436 assert_eq!(s.turn_count(), 1);
437 assert_eq!(s.chat_messages().len(), chat_before - 4);
439 assert_eq!(s.simple_messages().len(), msg_before - 3);
441 }
442
443 #[test]
444 fn test_pop_last_message_text() {
445 let mut s = make_session();
446 s.push_message(MessageRole::User, "hello");
447 s.push_message(MessageRole::Assistant, "hi");
448 assert_eq!(s.chat_messages().len(), 2);
449 s.pop_last_message();
450 assert_eq!(s.chat_messages().len(), 1);
451 assert_eq!(s.simple_messages().len(), 1);
452 }
453
454 #[test]
455 fn test_pop_last_message_tool_calls_only() {
456 let mut s = make_session();
457 s.push_message(MessageRole::User, "do it");
458 s.push_assistant_tool_calls(&[("id1".into(), "t".into(), "{}".into())], None);
459 assert_eq!(s.chat_messages().len(), 2);
460 assert_eq!(s.simple_messages().len(), 1); s.pop_last_message();
462 assert_eq!(s.chat_messages().len(), 1);
463 assert_eq!(s.simple_messages().len(), 1); }
465
466 #[test]
467 fn test_pop_last_message_empty_session() {
468 let mut s = make_session();
469 s.pop_last_message(); assert_eq!(s.chat_messages().len(), 0);
471 }
472
473 #[test]
476 fn test_id_and_action_allowlist() {
477 let mut s = make_session();
478 assert_eq!(s.id(), Some(SessionId::new(1)));
479 assert!(!s.is_action_allowed("approve:rm"));
480 s.allow_action("approve:rm");
481 assert!(s.is_action_allowed("approve:rm"));
482 assert!(!s.is_action_allowed("approve:shell"));
483 }
484
485 #[test]
486 fn test_chat_messages_mut() {
487 let mut s = make_session();
488 s.chat_messages_mut().push(ChatMessage::user("direct"));
489 assert_eq!(s.chat_messages().len(), 1);
490 }
491
492 #[test]
493 fn test_push_message_tool_role() {
494 let mut s = make_session();
495 s.push_message(MessageRole::Tool, "result");
496 assert!(matches!(s.chat_messages()[0], ChatMessage::Tool { .. }));
497 }
498
499 #[test]
500 fn test_push_assistant_with_reasoning() {
501 let mut s = make_session();
502 s.push_assistant_with_reasoning("answer", "thinking");
503 match &s.chat_messages()[0] {
504 ChatMessage::Assistant {
505 content,
506 reasoning_content,
507 ..
508 } => {
509 assert_eq!(content.as_deref(), Some("answer"));
510 assert_eq!(reasoning_content.as_deref(), Some("thinking"));
511 }
512 other => panic!("unexpected message: {other:?}"),
513 }
514 }
515
516 #[test]
517 fn test_push_user_message_with_images() {
518 let mut s = make_session();
519 s.push_user_message_with_images(
520 "look",
521 vec![ImageAttachment::Url {
522 url: "http://x".into(),
523 detail: None,
524 }],
525 );
526 match &s.chat_messages()[0] {
527 ChatMessage::User { images, .. } => assert_eq!(images.len(), 1),
528 other => panic!("unexpected message: {other:?}"),
529 }
530 }
531
532 #[test]
533 fn test_push_assistant_tool_call_singular() {
534 let mut s = make_session();
535 s.push_assistant_tool_call("call_1", "bash", "{}");
536 match &s.chat_messages()[0] {
537 ChatMessage::Assistant {
538 tool_calls: Some(tc),
539 ..
540 } => {
541 assert_eq!(tc.len(), 1);
542 assert_eq!(tc[0].id, "call_1");
543 assert_eq!(tc[0].name, "bash");
544 }
545 other => panic!("unexpected message: {other:?}"),
546 }
547 }
548
549 #[test]
550 fn test_simple_messages_filters_empty_content_tool_calls() {
551 let mut s = make_session();
552 s.chat_messages_mut().push(ChatMessage::Assistant {
553 content: Some(String::new()),
554 reasoning_content: None,
555 tool_calls: Some(vec![ToolCallMessage {
556 id: "c".into(),
557 name: "t".into(),
558 arguments: "{}".into(),
559 }]),
560 });
561 assert!(s.simple_messages().is_empty());
562 }
563
564 #[test]
565 fn test_remove_ephemeral_messages() {
566 let mut s = make_session();
567 s.push_message(MessageRole::System, "keep");
568 s.chat_messages_mut()
569 .push(ChatMessage::user_ephemeral("temp"));
570 s.chat_messages_mut()
571 .push(ChatMessage::system_ephemeral("temp2"));
572 s.push_message(MessageRole::User, "keep2");
573 assert_eq!(s.chat_messages().len(), 4);
574 s.remove_ephemeral_messages();
575 assert_eq!(s.chat_messages().len(), 2);
576 assert!(s.chat_messages().iter().all(|m| !m.is_ephemeral()));
577 }
578
579 #[test]
580 fn test_close_dangling_tool_calls_noop_without_tool_call() {
581 let mut s = make_session();
582 s.push_message(MessageRole::User, "hi");
583 s.push_message(MessageRole::Assistant, "hi");
584 s.close_dangling_tool_calls("failed");
585 assert_eq!(s.chat_messages().len(), 2);
586 }
587
588 #[test]
589 fn test_close_dangling_tool_calls_adds_missing_results() {
590 let mut s = make_session();
591 s.push_message(MessageRole::User, "do");
592 s.push_assistant_tool_calls(
593 &[
594 ("c1".into(), "t".into(), "{}".into()),
595 ("c2".into(), "t".into(), "{}".into()),
596 ],
597 None,
598 );
599 s.push_tool_result("c1", "ok"); s.close_dangling_tool_calls("failed");
601
602 let tool_results: Vec<(String, String)> = s
603 .chat_messages()
604 .iter()
605 .filter_map(|m| match m {
606 ChatMessage::Tool {
607 tool_call_id,
608 content,
609 } => Some((tool_call_id.clone(), content.clone())),
610 _ => None,
611 })
612 .collect();
613 assert_eq!(tool_results.len(), 2);
614 assert!(
615 tool_results
616 .iter()
617 .any(|(id, c)| id == "c2" && c == "failed")
618 );
619 }
620
621 #[test]
622 fn test_set_chat_messages_recalculates_total_tool_calls() {
623 let mut s = make_session();
624 let msgs = vec![
625 ChatMessage::user("do"),
626 ChatMessage::assistant_tool_call("c1", "t", "{}"),
627 ChatMessage::tool("c1", "result"),
628 ];
629 s.set_chat_messages(msgs).unwrap();
630 assert_eq!(s.total_tool_calls, 1);
631 }
632}
633
634#[cfg(test)]
635mod validate_tests {
636 use super::*;
637
638 #[test]
639 fn test_valid_simple_sequence() {
640 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
641 assert!(validate_message_sequence(&msgs).is_ok());
642 }
643
644 #[test]
645 fn test_valid_tool_call_sequence() {
646 let msgs = vec![
647 ChatMessage::user("run command"),
648 ChatMessage::assistant_tool_call("call_1", "bash", r#"{"cmd":"ls"}"#),
649 ChatMessage::tool("call_1", "file1 file2"),
650 ChatMessage::assistant("done"),
651 ];
652 assert!(validate_message_sequence(&msgs).is_ok());
653 }
654
655 #[test]
656 fn test_valid_multi_tool_call_sequence() {
657 let msgs = vec![
658 ChatMessage::user("run commands"),
659 ChatMessage::Assistant {
660 content: None,
661 reasoning_content: None,
662 tool_calls: Some(vec![
663 crate::types::ToolCallMessage {
664 id: "call_1".into(),
665 name: "bash".into(),
666 arguments: "{}".into(),
667 },
668 crate::types::ToolCallMessage {
669 id: "call_2".into(),
670 name: "read".into(),
671 arguments: "{}".into(),
672 },
673 ]),
674 },
675 ChatMessage::tool("call_1", "result1"),
676 ChatMessage::tool("call_2", "result2"),
677 ChatMessage::assistant("done"),
678 ];
679 assert!(validate_message_sequence(&msgs).is_ok());
680 }
681
682 #[test]
683 fn test_orphaned_tool_result() {
684 let msgs = vec![
685 ChatMessage::user("hello"),
686 ChatMessage::tool("call_1", "orphaned result"),
687 ];
688 let err = validate_message_sequence(&msgs).unwrap_err();
689 assert!(err.contains("no preceding tool_call"));
690 }
691
692 #[test]
693 fn test_mismatched_tool_call_id() {
694 let msgs = vec![
695 ChatMessage::user("run"),
696 ChatMessage::assistant_tool_call("call_1", "bash", "{}"),
697 ChatMessage::tool("call_2", "wrong id"),
698 ];
699 let err = validate_message_sequence(&msgs).unwrap_err();
700 assert!(err.contains("does not match"));
701 }
702
703 #[test]
704 fn test_set_chat_messages_valid() {
705 let mut s = make_session();
706 let msgs = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
707 assert!(s.set_chat_messages(msgs.clone()).is_ok());
708 assert_eq!(s.chat_messages().len(), 2);
709 }
710
711 #[test]
712 fn test_set_chat_messages_invalid() {
713 let mut s = make_session();
714 let msgs = vec![ChatMessage::tool("call_1", "orphaned")];
715 assert!(s.set_chat_messages(msgs).is_err());
716 }
717}