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