-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhistory.rs
More file actions
186 lines (170 loc) Β· 6.22 KB
/
history.rs
File metadata and controls
186 lines (170 loc) Β· 6.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
use crate::providers::ChatMessage;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
/// Default trigger for auto-compaction when non-system message count exceeds this threshold.
/// Prefer passing the config-driven value via `run_tool_call_loop`; this constant is only
/// used when callers omit the parameter.
pub(crate) const DEFAULT_MAX_HISTORY_MESSAGES: usize = 50;
/// Find the largest byte index `<= i` that is a valid char boundary.
/// MSRV-compatible replacement for `str::floor_char_boundary` (stable in 1.91).
pub(crate) fn floor_char_boundary(s: &str, i: usize) -> usize {
if i >= s.len() {
return s.len();
}
let mut pos = i;
while pos > 0 && !s.is_char_boundary(pos) {
pos -= 1;
}
pos
}
/// Truncate a tool result to `max_chars`, keeping head (2/3) + tail (1/3)
/// with a marker in the middle. Returns input unchanged if within limit or
/// `max_chars == 0` (disabled).
pub(crate) fn truncate_tool_result(output: &str, max_chars: usize) -> String {
if max_chars == 0 || output.len() <= max_chars {
return output.to_string();
}
let head_len = max_chars * 2 / 3;
let tail_len = max_chars.saturating_sub(head_len);
let head_end = floor_char_boundary(output, head_len);
// ceil_char_boundary: find smallest byte index >= i on a char boundary
let tail_start_raw = output.len().saturating_sub(tail_len);
let tail_start = if tail_start_raw >= output.len() {
output.len()
} else {
let mut pos = tail_start_raw;
while pos < output.len() && !output.is_char_boundary(pos) {
pos += 1;
}
pos
};
// Guard against overlap when max_chars is very small
if head_end >= tail_start {
return output[..floor_char_boundary(output, max_chars)].to_string();
}
let truncated_chars = tail_start - head_end;
format!(
"{}\n\n[... {} characters truncated ...]\n\n{}",
&output[..head_end],
truncated_chars,
&output[tail_start..]
)
}
/// Aggressively trim old tool result messages in history to recover from
/// context overflow. Keeps the last `protect_last_n` messages untouched.
/// Returns total characters saved.
pub(crate) fn fast_trim_tool_results(
history: &mut [crate::providers::ChatMessage],
protect_last_n: usize,
) -> usize {
let trim_to = 2000;
let mut saved = 0;
let cutoff = history.len().saturating_sub(protect_last_n);
for msg in &mut history[..cutoff] {
if msg.role == "tool" && msg.content.len() > trim_to {
let original_len = msg.content.len();
msg.content = truncate_tool_result(&msg.content, trim_to);
saved += original_len - msg.content.len();
}
}
saved
}
/// Emergency: drop oldest non-system, non-recent messages from history.
/// Tool groups (assistant + consecutive tool messages) are dropped
/// atomically to preserve tool_use/tool_result pairing.
/// Returns number of messages dropped.
pub(crate) fn emergency_history_trim(
history: &mut Vec<crate::providers::ChatMessage>,
keep_recent: usize,
) -> usize {
let mut dropped = 0;
let target_drop = history.len() / 3;
let mut i = 0;
while dropped < target_drop && i < history.len().saturating_sub(keep_recent) {
if history[i].role == "system" {
i += 1;
} else if history[i].role == "assistant" {
// Count following tool messages β drop as atomic group
let mut tool_count = 0;
while i + 1 + tool_count < history.len().saturating_sub(keep_recent)
&& history[i + 1 + tool_count].role == "tool"
{
tool_count += 1;
}
for _ in 0..=tool_count {
history.remove(i);
dropped += 1;
}
} else {
history.remove(i);
dropped += 1;
}
}
dropped
}
/// Estimate token count for a message history using ~4 chars/token heuristic.
/// Includes a small overhead per message for role/framing tokens.
pub(crate) fn estimate_history_tokens(history: &[ChatMessage]) -> usize {
history
.iter()
.map(|m| {
// ~4 chars per token + ~4 framing tokens per message (role, delimiters)
m.content.len().div_ceil(4) + 4
})
.sum()
}
/// Trim conversation history to prevent unbounded growth.
/// Preserves the system prompt (first message if role=system) and the most recent messages.
pub(crate) fn trim_history(history: &mut Vec<ChatMessage>, max_history: usize) {
// Nothing to trim if within limit
let has_system = history.first().map_or(false, |m| m.role == "system");
let non_system_count = if has_system {
history.len() - 1
} else {
history.len()
};
if non_system_count <= max_history {
return;
}
let start = if has_system { 1 } else { 0 };
let to_remove = non_system_count - max_history;
history.drain(start..start + to_remove);
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct InteractiveSessionState {
pub(crate) version: u32,
pub(crate) history: Vec<ChatMessage>,
}
impl InteractiveSessionState {
fn from_history(history: &[ChatMessage]) -> Self {
Self {
version: 1,
history: history.to_vec(),
}
}
}
pub(crate) fn load_interactive_session_history(
path: &Path,
system_prompt: &str,
) -> Result<Vec<ChatMessage>> {
if !path.exists() {
return Ok(vec![ChatMessage::system(system_prompt)]);
}
let raw = std::fs::read_to_string(path)?;
let mut state: InteractiveSessionState = serde_json::from_str(&raw)?;
if state.history.is_empty() {
state.history.push(ChatMessage::system(system_prompt));
} else if state.history.first().map(|msg| msg.role.as_str()) != Some("system") {
state.history.insert(0, ChatMessage::system(system_prompt));
}
Ok(state.history)
}
pub(crate) fn save_interactive_session_history(path: &Path, history: &[ChatMessage]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let payload = serde_json::to_string_pretty(&InteractiveSessionState::from_history(history))?;
std::fs::write(path, payload)?;
Ok(())
}