1use crate::{AgentError, CoreEvent, EventSink, Message};
4
5#[derive(Debug, Clone)]
6pub struct HistoryLimits {
7 pub max_bytes: usize,
8 pub max_messages: usize,
9 pub note_max_chars: usize,
10}
11
12impl Default for HistoryLimits {
13 fn default() -> Self {
14 Self {
15 max_bytes: 16 * 1024 * 1024,
16 max_messages: 10_000,
17 note_max_chars: 4_000,
18 }
19 }
20}
21
22pub(crate) async fn enforce_limits(
27 history: &mut Vec<Message>,
28 limits: &HistoryLimits,
29 sink: &dyn EventSink,
30) -> Result<(), AgentError> {
31 let mut total_removed = 0;
32 while history.len() > limits.max_messages || history_bytes(history) > limits.max_bytes {
33 let latest_user = history
34 .iter()
35 .rposition(|message| matches!(message, Message::User { .. }))
36 .unwrap_or(0);
37 let active = &history[latest_user..];
38 if active.len() > limits.max_messages || history_bytes(active) > limits.max_bytes {
39 return Err(AgentError::HistoryLimit(
40 "active turn exceeds configured session history limit".into(),
41 ));
42 }
43 let first_user = history
44 .iter()
45 .position(|message| matches!(message, Message::User { .. }))
46 .unwrap_or(latest_user);
47 if first_user == latest_user {
48 if matches!(history.first(), Some(Message::HistoryNote { .. })) {
49 history.remove(0);
50 total_removed += 1;
51 continue;
52 }
53 return Err(AgentError::HistoryLimit(
54 "session history cannot be reduced within its configured limit".into(),
55 ));
56 }
57 let end = history[first_user + 1..]
58 .iter()
59 .position(|message| matches!(message, Message::User { .. }))
60 .map(|index| first_user + 1 + index)
61 .ok_or_else(|| {
62 AgentError::HistoryLimit(
63 "session history has no complete group available to trim".into(),
64 )
65 })?;
66 let removed: Vec<Message> = history.drain(..end).collect();
67 total_removed += removed.len();
68 let note = Message::HistoryNote {
69 content: summarize_history_trim(&removed, total_removed, limits.note_max_chars),
70 };
71 if matches!(history.first(), Some(Message::HistoryNote { .. })) {
72 history.remove(0);
73 }
74 history.insert(0, note);
75 }
76 if total_removed > 0 {
77 sink.emit(CoreEvent::SessionTrimmed {
78 removed_messages: total_removed,
79 history_bytes: history_bytes(history),
80 })
81 .await?;
82 }
83 Ok(())
84}
85
86fn history_bytes(history: &[Message]) -> usize {
87 serde_json::to_vec(history).map_or(usize::MAX, |value| value.len())
88}
89
90fn summarize_history_trim(messages: &[Message], removed: usize, max_chars: usize) -> String {
91 let mut note =
92 format!("[SCV trimmed {removed} earlier canonical messages to enforce session limits.]\n");
93 for message in messages {
94 let (label, content) = match message {
95 Message::User { content, .. } => ("user", content.as_str()),
96 Message::Assistant { content, .. } => ("assistant", content.as_str()),
97 Message::Tool {
98 name,
99 content,
100 is_error,
101 ..
102 } => {
103 let status = if *is_error { "failed" } else { "ok" };
104 note.push_str(&format!("tool {name} ({status}): "));
105 ("", content.as_str())
106 }
107 Message::HistoryNote { content } => ("earlier", content.as_str()),
108 };
109 if !label.is_empty() {
110 note.push_str(label);
111 note.push_str(": ");
112 }
113 note.push_str(&char_tail(content, 160).replace('\n', " "));
114 note.push('\n');
115 if note.chars().count() >= max_chars {
116 break;
117 }
118 }
119 truncate_chars(¬e, max_chars)
120}
121
122pub(crate) fn truncate_chars(value: &str, max_chars: usize) -> String {
123 value.chars().take(max_chars).collect()
124}
125
126pub(crate) fn char_tail(value: &str, max_chars: usize) -> String {
127 let count = value.chars().count();
128 value
129 .chars()
130 .skip(count.saturating_sub(max_chars))
131 .collect()
132}