Skip to main content

scv_core/
history.rs

1//! Keeping the canonical session history within its configured limits.
2
3use 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
22/// Trim the oldest complete turns until `history` is within `limits`, keeping
23/// one [`Message::HistoryNote`] in their place. The active turn (from the
24/// newest user message on) is never trimmed: if it alone is over a limit, the
25/// turn fails and the runtime rolls it back.
26pub(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(&note, 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}