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 { 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    /// 可变引用,仅用于需要直接操作消息的高级场景。
68    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    /// Push an assistant message with reasoning/thinking content preserved.
92    /// This allows the LLM to see its own prior reasoning in subsequent turns,
93    /// preventing it from re-deriving the same conclusions every turn.
94    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    /// 移除所有临时消息(ephemeral=true)。
151    ///
152    /// 在 turn 结束时调用,确保注入的临时内容不残留到下一轮。
153    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    /// Count the number of conversation turns.
167    /// A turn starts with a User message and includes subsequent Assistant/Tool messages.
168    pub fn turn_count(&self) -> usize {
169        self.chat_messages
170            .iter()
171            .filter(|m| matches!(m, ChatMessage::User { .. }))
172            .count()
173    }
174
175    /// Remove the oldest turns from the front until turn count ≤ max_turns.
176    /// Preserves the System message at index 0 if present.
177    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        // Find User message positions (turn boundaries) in chat_messages
185        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        // Preserve system prefix: count leading System messages
203        let system_prefix = self
204            .chat_messages
205            .iter()
206            .take_while(|m| matches!(m, ChatMessage::System { .. }))
207            .count();
208
209        // Drain from system_prefix up to the start of the (turns_to_remove + 1)-th turn
210        let drain_end = user_positions[turns_to_remove];
211        if system_prefix >= drain_end {
212            return; // nothing to drain after system messages
213        }
214
215        self.chat_messages.drain(system_prefix..drain_end);
216    }
217
218    /// Remove the last message from `chat_messages`.
219    /// Used by the max_message_tokens safety valve to discard oversized messages.
220    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    /// Replace chat messages — only for persistence restore.
259    /// Validates message sequence before replacing.
260    ///
261    /// 仅供持久化恢复使用。调用方必须保证 messages 序列合法。
262    pub fn set_chat_messages(&mut self, messages: Vec<ChatMessage>) -> Result<(), String> {
263        validate_message_sequence(&messages)?;
264        // Recalculate total_tool_calls from the incoming messages so middleware
265        // decisions (e.g. first_turn_only enforcement) see the correct count.
266        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
281/// Validate that a chat message sequence is well-formed for LLM API consumption.
282///
283/// Checks:
284/// - No Tool message without a preceding Assistant with matching tool_call
285/// - No duplicate Tool messages for the same tool_call_id
286/// - All tool_calls in an Assistant batch must be answered before the next Assistant batch
287/// - No unanswered tool calls at the end of the sequence
288pub 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                // Remove the ID on match — also detects duplicates (second remove returns false)
301                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                // Previous batch must be fully answered before a new batch starts
313                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    // All tool calls must be answered by the end of the sequence
326    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        // One user turn: User -> Assistant(tool_calls) -> Tool -> Assistant(text)
372        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        // Turn 1
390        s.push_message(MessageRole::User, "u1");
391        s.push_message(MessageRole::Assistant, "a1");
392        // Turn 2
393        s.push_message(MessageRole::User, "u2");
394        s.push_message(MessageRole::Assistant, "a2");
395        // Turn 3
396        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        // System message preserved
402        assert!(matches!(s.chat_messages()[0], ChatMessage::System { .. }));
403        // Oldest user message is u2
404        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        // Turn 1 with tool call
413        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        // Turn 2
418        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        // chat_messages should have lost 4 entries (User, Assistant(tool), Tool, Assistant(text))
426        assert_eq!(s.chat_messages().len(), chat_before - 4);
427        // simple_messages (derived from chat_messages, tool_calls-only filtered) loses 3 entries
428        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); // only User in simple_messages (tool_calls-only filtered)
449        s.pop_last_message();
450        assert_eq!(s.chat_messages().len(), 1);
451        assert_eq!(s.simple_messages().len(), 1); // simple_messages unchanged (still just User)
452    }
453
454    #[test]
455    fn test_pop_last_message_empty_session() {
456        let mut s = make_session();
457        s.pop_last_message(); // should not panic
458        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}