Skip to main content

agent_base/engine/
session.rs

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    /// LLM API format messages, sent directly to the provider.
13    /// This is the single source of truth for the conversation state.
14    chat_messages: Vec<ChatMessage>,
15    always_allowed_actions: HashSet<String>,
16    /// Total number of tool calls made in this session (across all turns).
17    /// Used by middleware for decisions like "first_turn_only" enforcement.
18    pub total_tool_calls: usize,
19    /// Number of tool-enforcement nudges issued in the current turn.
20    /// Reset to 0 at the start of each turn (when a new user message arrives).
21    /// Used by `ToolEnforcementMiddleware` to cap nudge attempts per turn.
22    pub nudge_count: usize,
23    /// Number of tool calls already executed in the current turn.
24    /// Reset to 0 at the start of each turn (when a new user message arrives).
25    /// Used by `TurnToolLimitMiddleware` to enforce per-turn tool call limits.
26    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    /// Derive a simplified `Vec<Message>` view from the canonical `chat_messages`.
46    /// Assistant messages that contain only tool_calls (no text content) are
47    /// skipped, since they have no corresponding simplified representation.
48    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    /// 可变引用,仅用于需要直接操作消息的高级场景。
70    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    /// Push an assistant message with reasoning/thinking content preserved.
94    /// This allows the LLM to see its own prior reasoning in subsequent turns,
95    /// preventing it from re-deriving the same conclusions every turn.
96    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    /// 移除所有临时消息(ephemeral=true)。
153    ///
154    /// 在 turn 结束时调用,确保注入的临时内容不残留到下一轮。
155    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    /// Count the number of conversation turns.
165    /// A turn starts with a User message and includes subsequent Assistant/Tool messages.
166    pub fn turn_count(&self) -> usize {
167        self.chat_messages
168            .iter()
169            .filter(|m| matches!(m, ChatMessage::User { .. }))
170            .count()
171    }
172
173    /// Remove the oldest turns from the front until turn count ≤ max_turns.
174    /// Preserves the System message at index 0 if present.
175    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        // Find User message positions (turn boundaries) in chat_messages
183        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        // Preserve system prefix: count leading System messages
195        let system_prefix = self
196            .chat_messages
197            .iter()
198            .take_while(|m| matches!(m, ChatMessage::System { .. }))
199            .count();
200
201        // Drain from system_prefix up to the start of the (turns_to_remove + 1)-th turn
202        let drain_end = user_positions[turns_to_remove];
203        if system_prefix >= drain_end {
204            return; // nothing to drain after system messages
205        }
206
207        self.chat_messages.drain(system_prefix..drain_end);
208    }
209
210    /// Remove the last message from `chat_messages`.
211    /// Used by the max_message_tokens safety valve to discard oversized messages.
212    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    /// Replace chat messages — only for persistence restore.
247    /// Validates message sequence before replacing.
248    ///
249    /// 仅供持久化恢复使用。调用方必须保证 messages 序列合法。
250    pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
251        validate_message_sequence(&messages)?;
252        // Recalculate total_tool_calls from the incoming messages so middleware
253        // decisions (e.g. first_turn_only enforcement) see the correct count.
254        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
268/// Validate that a chat message sequence is well-formed for LLM API consumption.
269///
270/// Checks:
271/// - No Tool message without a preceding Assistant with matching tool_call
272/// - No duplicate Tool messages for the same tool_call_id
273/// - All tool_calls in an Assistant batch must be answered before the next Assistant batch
274/// - No unanswered tool calls at the end of the sequence
275pub 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                // Remove the ID on match — also detects duplicates (second remove returns false)
288                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                // Previous batch must be fully answered before a new batch starts
299                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    // All tool calls must be answered by the end of the sequence
312    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        // One user turn: User -> Assistant(tool_calls) -> Tool -> Assistant(text)
358        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        // Turn 1
376        s.push_message(MessageRole::User, "u1");
377        s.push_message(MessageRole::Assistant, "a1");
378        // Turn 2
379        s.push_message(MessageRole::User, "u2");
380        s.push_message(MessageRole::Assistant, "a2");
381        // Turn 3
382        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        // System message preserved
388        assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
389        // Oldest user message is u2
390        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        // Turn 1 with tool call
397        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        // Turn 2
402        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        // chat_messages should have lost 4 entries (User, Assistant(tool), Tool, Assistant(text))
410        assert_eq!(s.chat_messages().len(), chat_before - 4);
411        // simple_messages (derived from chat_messages, tool_calls-only filtered) loses 3 entries
412        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); // only User in simple_messages (tool_calls-only filtered)
433        s.pop_last_message();
434        assert_eq!(s.chat_messages().len(), 1);
435        assert_eq!(s.simple_messages().len(), 1); // simple_messages unchanged (still just User)
436    }
437
438    #[test]
439    fn test_pop_last_message_empty_session() {
440        let mut s = make_session();
441        s.pop_last_message(); // should not panic
442        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}