lean_ctx_proxy/
session_elide.rs1use serde_json::Value;
47
48pub const THRESHOLD_BYTES: usize = 180_000;
51
52pub const MIN_ELIDE_BYTES: usize = 2_000;
55
56pub const STUB_OPEN: &str = "[lean-ctx: earlier tool output elided";
58
59fn serialized_len(messages: &[Value]) -> usize {
61 struct Count(usize);
62 impl std::io::Write for Count {
63 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
64 self.0 += buf.len();
65 Ok(buf.len())
66 }
67 fn flush(&mut self) -> std::io::Result<()> {
68 Ok(())
69 }
70 }
71 let mut count = Count(0);
72 let _ = serde_json::to_writer(&mut count, messages);
73 count.0
74}
75
76pub fn elide(messages: &mut [Value], threshold: usize) -> Option<usize> {
80 if serialized_len(messages) <= threshold {
81 return None;
82 }
83 let boundary = super::history_prune::prune_boundary(
84 lean_ctx::core::config::HistoryMode::CacheAware,
85 messages.len(),
86 );
87 let mut last = None;
88 for (index, message) in messages[..boundary].iter_mut().enumerate() {
89 if message.get("role").and_then(Value::as_str) != Some("user") {
90 continue;
91 }
92 let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) else {
93 continue;
94 };
95 for block in blocks {
96 if elide_block(block) {
97 last = Some(index);
98 }
99 }
100 }
101 last
102}
103
104fn elidable_text(content: &Value) -> Option<String> {
108 match content {
109 Value::String(text) => Some(text.clone()),
110 Value::Array(items) => {
111 let mut parts = Vec::with_capacity(items.len());
112 for item in items {
113 if item.get("type").and_then(Value::as_str) != Some("text")
114 || item.get("cache_control").is_some()
115 {
116 return None;
117 }
118 parts.push(item.get("text")?.as_str()?);
119 }
120 Some(parts.join("\n"))
121 }
122 _ => None,
123 }
124}
125
126fn elide_block(block: &mut Value) -> bool {
129 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
130 return false;
131 }
132 let Some(original) = block.get("content").and_then(elidable_text) else {
133 return false;
134 };
135 if original.len() <= MIN_ELIDE_BYTES
136 || original.starts_with(STUB_OPEN)
137 || original.contains("<protect>")
138 {
139 return false;
140 }
141 let chars = original.chars().count();
142 let stub = if super::compress::protected() {
143 let call_id = block.get("tool_use_id").and_then(Value::as_str);
144 let _block = super::sticky_tools::block(call_id);
145 let Some(stub) = super::compress::shorten_with_copy(
146 &original,
147 &format!("{STUB_OPEN} ({chars} chars).]"),
148 ) else {
149 return false;
150 };
151 stub
152 } else {
153 let Some(path) = super::ccr::persist_once(&original) else {
155 return false;
156 };
157 format!(
158 "{STUB_OPEN} ({chars} chars) to keep this long session lean; full text: {}]",
159 locator(std::path::Path::new(&path))
160 )
161 };
162 block["content"] = Value::String(stub);
163 true
164}
165
166fn locator(path: &std::path::Path) -> String {
170 if let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty())
171 && let Ok(rest) = path.strip_prefix(&home)
172 {
173 return format!("cat ~/{}", rest.display());
174 }
175 let name = path
176 .file_name()
177 .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
178 format!("ctx_expand(id=\"{name}\") (LeanCTX state directory, tee/{name})")
179}
180
181pub fn message_is_elided(message: &Value) -> bool {
183 message
184 .get("content")
185 .and_then(Value::as_array)
186 .is_some_and(|blocks| {
187 blocks.iter().any(|block| {
188 block.get("type").and_then(Value::as_str) == Some("tool_result")
189 && block
190 .get("content")
191 .and_then(Value::as_str)
192 .is_some_and(|text| text.starts_with(STUB_OPEN))
193 })
194 })
195}
196
197pub fn newly_elided(prefix_bytes: &[u8], last: usize) -> bool {
202 let Ok(previous) = serde_json::from_slice::<Value>(prefix_bytes) else {
203 return true;
204 };
205 previous
206 .get("messages")
207 .and_then(Value::as_array)
208 .and_then(|messages| messages.get(last))
209 .is_some_and(|message| !message_is_elided(message))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use serde_json::json;
216
217 fn session(turns: usize, size: usize) -> Vec<Value> {
219 let mut messages = vec![json!({"role": "user", "content": "fix the build"})];
220 for turn in 0..turns {
221 let id = format!("t{turn}");
222 messages.push(json!({"role": "assistant", "content": [
223 {"type": "tool_use", "id": id, "name": "Bash", "input": {"command": "make"}}
224 ]}));
225 let output = format!("output {turn} line\n").repeat(size / 15);
226 messages.push(json!({"role": "user", "content": [
227 {"type": "tool_result", "tool_use_id": id, "content": output}
228 ]}));
229 }
230 let last = messages.len() - 1;
231 messages[last]["content"][0]["cache_control"] = json!({"type": "ephemeral", "ttl": "1h"});
232 messages
233 }
234
235 #[test]
236 fn below_the_threshold_nothing_changes() {
237 let _data = lean_ctx::core::data_dir::isolated_data_dir();
238 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
239 let mut messages = session(20, 5_000);
240 let before = messages.clone();
241 assert_eq!(elide(&mut messages, THRESHOLD_BYTES), None);
242 assert_eq!(messages, before);
243 }
244
245 #[test]
246 fn old_large_results_leave_as_a_stub_that_names_the_original() {
247 let _data = lean_ctx::core::data_dir::isolated_data_dir();
248 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
249 let mut messages = session(30, 5_000);
250 messages[2]["content"][0]["content"] = json!("ok");
252 messages[4]["content"][0]["content"] = json!([{"type": "text", "text": "x".repeat(5_000), "cache_control": {"type": "ephemeral"}}]);
253 let original = messages[6]["content"][0]["content"]
254 .as_str()
255 .unwrap()
256 .to_owned();
257 let before = messages.clone();
258
259 let last = elide(&mut messages, 100_000).expect("elided");
260 let boundary = (messages.len() - 8) / 16 * 16;
261 assert_eq!(last, boundary - 2, "the window ends at the stride boundary");
264
265 assert_eq!(messages[2], before[2], "small result untouched");
266 assert_eq!(messages[4], before[4], "nested marker untouched");
267 let stub = messages[6]["content"][0]["content"].as_str().unwrap();
268 assert!(stub.starts_with(STUB_OPEN), "{stub}");
269 let path = if let Some(rest) = stub.split("cat ~/").nth(1) {
270 std::path::PathBuf::from(std::env::var_os("HOME").unwrap())
271 .join(rest.trim_end_matches(']'))
272 } else {
273 let id = stub
274 .split("id=\"")
275 .nth(1)
276 .unwrap()
277 .split('"')
278 .next()
279 .unwrap();
280 lean_ctx::core::ccr::resolve_tee(id).expect("archive resolves")
281 };
282 if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) {
283 assert!(!stub.contains(&home), "no account path in the stub: {stub}");
284 }
285 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
287 #[cfg(unix)]
288 {
289 use std::os::unix::fs::PermissionsExt;
290 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
291 assert_eq!(mode & 0o777, 0o600, "the archive is private");
292 }
293 assert!(message_is_elided(&messages[6]));
294 assert_eq!(
295 messages[boundary..],
296 before[boundary..],
297 "the live window, with its cache marker, is untouched"
298 );
299
300 let once = messages.clone();
303 std::fs::remove_file(&path).unwrap();
304 let mut again = before.clone();
305 elide(&mut again, 100_000);
306 assert_eq!(again, once, "content-deterministic stub (#498)");
307 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
308 }
309
310 #[test]
311 fn the_prefix_stays_byte_identical_within_a_stride() {
312 let _data = lean_ctx::core::data_dir::isolated_data_dir();
313 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
314 let mut turn = session(30, 5_000);
316 let mut next = session(31, 5_000);
317 let mut jump = session(36, 5_000);
318 elide(&mut turn, 100_000);
319 elide(&mut next, 100_000);
320 let last = elide(&mut jump, 100_000).unwrap();
321 let prefix = |m: &[Value]| serde_json::to_vec(&m[..48]).unwrap();
322 assert_eq!(prefix(&turn), prefix(&next));
323 assert_eq!(prefix(&turn), prefix(&jump));
324
325 let forwarded = serde_json::to_vec(&json!({"messages": next})).unwrap();
326 assert!(newly_elided(&forwarded, last), "the stride jump is new");
327 let forwarded = serde_json::to_vec(&json!({"messages": jump})).unwrap();
328 assert!(!newly_elided(&forwarded, last), "already sent elided");
329 }
330}