1use super::ccr;
2use crate::core::tokens::count_tokens;
3use crate::core::web::distill;
4
5const RESEARCH_PROSE_CAP: usize = 20_000;
9const RESEARCH_PROSE_CAP_ENV: &str = "LEAN_CTX_RESEARCH_PROSE_CAP";
10
11fn research_prose_cap() -> usize {
12 std::env::var(RESEARCH_PROSE_CAP_ENV)
13 .ok()
14 .and_then(|v| v.trim().parse::<usize>().ok())
15 .filter(|cap| *cap > 0)
16 .unwrap_or(RESEARCH_PROSE_CAP)
17}
18
19pub fn compress_tool_result(content: &str, tool_name: Option<&str>) -> String {
31 let compressed = compress_inner(content, tool_name);
32 attach_ccr(content, compressed, CcrAudience::Local)
33}
34
35pub fn compress_tool_result_gateway(content: &str, tool_name: Option<&str>) -> String {
45 let compressed = compress_inner(content, tool_name);
46 let clean = crate::core::protocol::strip_trailing_savings_footer(&compressed).to_string();
47 attach_ccr(content, clean, CcrAudience::Gateway)
48}
49
50#[derive(Clone, Copy, PartialEq)]
53enum CcrAudience {
54 Local,
55 Gateway,
56}
57
58fn attach_ccr(original: &str, result: String, audience: CcrAudience) -> String {
65 if original.len() < ccr::MIN_TEE_BYTES
66 || original.len().saturating_sub(result.len()) < ccr::MIN_TEE_BYTES
67 {
68 return result;
69 }
70 match ccr::persist(original) {
71 Some(handle) if audience == CcrAudience::Gateway => {
79 let hash = ccr::litellm_hash(original);
80 format!(
81 "{result}\n[lean-ctx CCR: full original elided to save tokens — call the \
82 retrieve tool with hash={hash}, or read {handle} locally]"
83 )
84 }
85 Some(handle) => match ccr::inband_locator(&handle) {
86 Some(marker) => format!(
90 "{result}\n[lean-ctx: full original elided to save tokens — echo {marker} \
91 on your next turn to get the verbatim original spliced back inline]"
92 ),
93 None => format!(
95 "{result}\n[lean-ctx: full original at {handle} — read it directly (no MCP), or \
96 ctx_expand(id=\"{handle}\", head=N|search=\"…\"|json_path=\"…\") for a slice]"
97 ),
98 },
99 None => result,
100 }
101}
102
103fn compress_inner(content: &str, tool_name: Option<&str>) -> String {
104 if content.trim().is_empty() || content.len() < 200 {
105 return content.to_string();
106 }
107
108 if tool_name.is_some_and(is_lean_ctx_tool) {
115 return content.to_string();
116 }
117
118 if crate::core::protect::has_markers(content) {
122 return crate::core::protect::compress_preserving(content, |seg| {
123 compress_inner(seg, tool_name)
124 });
125 }
126
127 if is_cited_research_output(content) {
128 return content.to_string();
129 }
130
131 if extract_command_hint(content).is_none()
132 && looks_like_prose(content)
133 && let Some(out) = squeeze_research_prose(content)
134 {
135 return out;
136 }
137
138 let cmd = infer_command(content, tool_name);
139
140 let generic_command = cmd.is_empty() || cmd == "shell";
147 if generic_command
148 && (output_looks_like_test_run(content) || output_looks_like_build_failure(content))
149 {
150 return crate::shell::compress::engine::preserve_verbatim_pub(content);
151 }
152
153 crate::shell::compress::engine::compress_if_beneficial(&cmd, content)
154}
155
156fn output_looks_like_test_run(content: &str) -> bool {
160 const NEEDLES: &[&str] = &[
161 "test result:", "short test summary info", " passed in ", " failed in ", "=== RUN", "--- FAIL:", "--- PASS:", "Test Suites:", " examples, ", "FAILED", ];
172 NEEDLES.iter().any(|n| content.contains(n))
173}
174
175fn output_looks_like_build_failure(content: &str) -> bool {
179 const NEEDLES: &[&str] = &[
180 "error[", ": error:", "fatal error:", "undefined reference to", "panicked at", "could not compile", "Traceback (most recent call last)", "AssertionError", "make: ***", "Build FAILED",
190 "BUILD FAILED",
191 "Segmentation fault",
192 ];
193 NEEDLES.iter().any(|n| content.contains(n))
194}
195
196fn is_cited_research_output(content: &str) -> bool {
199 content.contains("· Retrieved: ") && content.contains("\nSource: ")
200}
201
202const CODE_SYMBOLS: &str = "{}<>;=|\\$`";
204
205fn looks_like_prose(content: &str) -> bool {
208 let sample: String = content.chars().take(4000).collect();
209 let total = sample.chars().count();
210 if total < 600 {
211 return false;
212 }
213 let total_f = total as f32;
214 let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
215 let spaces = sample.chars().filter(|c| *c == ' ').count() as f32;
216 let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
217
218 if alpha / total_f < 0.6 || spaces / total_f < 0.12 || symbols / total_f > 0.06 {
219 return false;
220 }
221 if sample.matches(['.', '!', '?']).count() < 4 {
222 return false;
223 }
224
225 let non_empty: Vec<&str> = sample.lines().filter(|l| !l.trim().is_empty()).collect();
226 if non_empty.is_empty() {
227 return false;
228 }
229 let avg_len =
230 non_empty.iter().map(|l| l.chars().count()).sum::<usize>() as f32 / non_empty.len() as f32;
231 avg_len >= 40.0
232}
233
234fn squeeze_research_prose(content: &str) -> Option<String> {
237 let before = count_tokens(content);
238 let squeezed = squeeze_research_prose_body(content);
239 if squeezed.trim().is_empty() {
240 return None;
241 }
242 let after = count_tokens(&squeezed);
243 if after + 2 >= before {
244 return None;
245 }
246 Some(crate::core::protocol::append_savings_with_info(
247 &squeezed,
248 before,
249 after,
250 Some("research"),
251 None,
252 ))
253}
254
255fn squeeze_research_prose_body(content: &str) -> String {
263 let cap = research_prose_cap();
264 if content.len() > cap {
265 return super::prose_ranker::squeeze(content, cap);
266 }
267 distill::squeeze_prose(content, cap)
268}
269
270fn is_lean_ctx_tool(name: &str) -> bool {
279 let bare = name
280 .rsplit("__")
281 .next()
282 .unwrap_or(name)
283 .rsplit([':', '/', '.'])
284 .next()
285 .unwrap_or(name);
286 bare.starts_with("ctx_") || name.starts_with("ctx_")
287}
288
289fn infer_command(content: &str, tool_name: Option<&str>) -> String {
290 if let Some(cmd) = extract_command_hint(content) {
291 return cmd;
292 }
293
294 if let Some(name) = tool_name {
295 let nl = name.to_lowercase();
296 if nl.contains("bash") || nl.contains("shell") || nl.contains("terminal") {
297 return "shell".to_string();
298 }
299 if nl.contains("search") || nl.contains("grep") || nl.contains("find") {
300 return "grep".to_string();
301 }
302 }
303
304 String::new()
305}
306
307fn extract_command_hint(content: &str) -> Option<String> {
308 for line in content.lines().take(3) {
309 let trimmed = line.trim();
310 if let Some(cmd) = trimmed.strip_prefix("$ ") {
311 return Some(cmd.to_string());
312 }
313 if let Some(cmd) = trimmed.strip_prefix("% ") {
314 return Some(cmd.to_string());
315 }
316 }
317 None
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use serial_test::serial;
324
325 #[test]
326 fn short_content_unchanged() {
327 let short = "hello world";
328 assert_eq!(compress_tool_result(short, None), short);
329 }
330
331 #[test]
332 fn empty_content_unchanged() {
333 assert_eq!(compress_tool_result("", None), "");
334 assert_eq!(compress_tool_result(" ", None), " ");
335 }
336
337 #[test]
338 fn command_hint_extraction() {
339 assert_eq!(
340 extract_command_hint("$ cargo build\nCompiling foo"),
341 Some("cargo build".to_string())
342 );
343 assert_eq!(extract_command_hint("no prefix here"), None);
344 }
345
346 #[test]
347 fn tool_name_inference() {
348 assert_eq!(infer_command("some text", Some("bash_execute")), "shell");
349 assert_eq!(infer_command("some text", Some("search_files")), "grep");
350 assert_eq!(infer_command("some text", Some("unknown_tool")), "");
351 }
352
353 #[test]
354 fn lean_ctx_tool_results_pass_through_verbatim() {
355 let raw = (1..=120)
359 .map(|i| format!("Line {i:04}: the quick brown fox jumps over the lazy dog"))
360 .collect::<Vec<_>>()
361 .join("\n");
362 assert!(raw.len() > 200);
363 for tool in [
366 "ctx_shell",
367 "ctx_read",
368 "ctx_search",
369 "ctx_grep",
370 "mcp__lean-ctx__ctx_shell",
371 "lean-ctx:ctx_read",
372 ] {
373 assert_eq!(
374 compress_tool_result(&raw, Some(tool)),
375 raw,
376 "{tool} output must pass through the proxy verbatim"
377 );
378 }
379 assert_ne!(
382 compress_tool_result(&raw, Some("bash")),
383 raw,
384 "foreign-tool output should still be compressed by the proxy"
385 );
386 }
387
388 #[test]
389 fn cited_research_output_is_preserved_verbatim() {
390 let cited = format!(
391 "Rust is a language.\n\n---\nSource: Rust — https://x.com/a\n\
392 Site: x.com · Retrieved: 2026-06-06T00:00:00Z\n{}",
393 "Extra body line that would otherwise be touched. ".repeat(20)
394 );
395 assert_eq!(compress_tool_result(&cited, Some("ctx_url_read")), cited);
396 }
397
398 #[test]
399 fn prose_is_squeezed_and_deduped() {
400 let para = "Rust is a multi-paradigm systems programming language that \
401 emphasizes performance, type safety, and fearless concurrency, \
402 achieving memory safety without a garbage collector at runtime.";
403 let input = format!("{}\n", [para; 8].join("\n\n"));
405 assert!(input.len() > 600);
406 let out = compress_tool_result(&input, Some("web_fetch"));
407 assert_eq!(out.matches("fearless concurrency").count(), 1);
408 assert!(out.contains("performance, type safety"));
409 }
410
411 #[test]
412 #[serial]
413 fn research_prose_cap_env_overrides_default() {
414 let _lock = crate::core::data_dir::test_env_lock();
415 crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, "1234");
416 assert_eq!(research_prose_cap(), 1234);
417 crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
418 }
419
420 #[test]
421 #[serial]
422 fn research_prose_cap_env_invalid_falls_back() {
423 let _lock = crate::core::data_dir::test_env_lock();
424 for value in ["", "not_a_number", "0"] {
425 crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, value);
426 assert_eq!(research_prose_cap(), RESEARCH_PROSE_CAP);
427 }
428 crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
429 }
430
431 #[test]
432 fn code_output_is_not_treated_as_prose() {
433 let code = "fn main() {\n let x = vec![1, 2, 3];\n \
434 for i in &x { println!(\"{}\", i); }\n}\n"
435 .repeat(20);
436 assert!(!looks_like_prose(&code));
437 }
438
439 #[test]
440 fn shell_log_is_not_treated_as_prose() {
441 let log = "$ cargo build\n Compiling foo v0.1.0\n Finished dev\n".repeat(20);
442 assert!(!looks_like_prose(&log));
443 }
444
445 #[test]
446 fn foreign_shell_build_failure_preserved_verbatim() {
447 let mut log = String::from("gcc -O2 -c src/versioncmp.c -o versioncmp.o\n");
451 log.push_str("src/versioncmp.c: In function 'version_cmp':\n");
452 log.push_str(
453 "src/versioncmp.c:142:17: error: invalid operands to binary < (have 'char *' and 'int')\n",
454 );
455 for i in 0..40 {
456 log.push_str(&format!(" note: expansion context line {i}\n"));
457 }
458 log.push_str("make: *** [Makefile:23: versioncmp.o] Error 1\n");
459
460 let out = compress_tool_result(&log, Some("shell"));
461 assert!(
462 out.contains("versioncmp.c:142:17: error:"),
463 "compiler error must survive the proxy"
464 );
465 assert!(
466 out.contains("make: ***"),
467 "make failure summary must survive"
468 );
469 }
470
471 #[test]
472 fn foreign_shell_test_failure_preserved_verbatim() {
473 let mut log = String::from("running 3 tests\n");
474 log.push_str("test version::tests::sorts_numeric ... FAILED\n");
475 for i in 0..40 {
476 log.push_str(&format!("note line {i} with some filler content here\n"));
477 }
478 log.push_str("test result: FAILED. 2 passed; 1 failed; 0 ignored\n");
479
480 let out = compress_tool_result(&log, Some("bash"));
481 assert!(
482 out.contains("test result: FAILED"),
483 "test summary must survive the proxy"
484 );
485 assert!(out.contains("sorts_numeric ... FAILED"));
486 }
487
488 #[test]
489 fn plain_shell_log_not_forced_verbatim() {
490 let log = "Listening on port 8080\nRequest received from 10.0.0.2\n".repeat(20);
491 assert!(!output_looks_like_test_run(&log));
492 assert!(!output_looks_like_build_failure(&log));
493 }
494
495 fn big_compressible_log() -> String {
496 (1..=400)
497 .map(|i| format!("[info] processed item {i:04} ok"))
498 .collect::<Vec<_>>()
499 .join("\n")
500 }
501
502 #[test]
503 fn live_compression_is_recoverable_via_ccr_handle() {
504 let _lock = crate::core::data_dir::test_env_lock();
505 let log = big_compressible_log();
506 let out = compress_tool_result(&log, Some("bash"));
507 assert!(
508 out.len() < log.len(),
509 "a large foreign log must be compressed"
510 );
511
512 let handle = ccr::persist(&log).expect("same content -> same handle");
516 assert!(out.contains(&handle), "CCR handle must be embedded: {out}");
517 let recovered = std::fs::read_to_string(&handle).expect("tee file readable");
518 assert!(
519 recovered.contains("processed item 0007 ok")
520 && recovered.contains("processed item 0400 ok"),
521 "verbatim original must be fully recoverable"
522 );
523 }
524
525 #[test]
526 fn live_compression_output_is_byte_stable_across_turns() {
527 let _lock = crate::core::data_dir::test_env_lock();
528 let log = big_compressible_log();
529 let a = compress_tool_result(&log, Some("bash"));
530 let b = compress_tool_result(&log, Some("bash"));
531 assert_eq!(
532 a, b,
533 "the CCR handle is content-addressed, so the rewritten result must be \
534 byte-identical across turns (provider cache prefix stays valid, #448)"
535 );
536 }
537
538 #[test]
544 fn gateway_stub_matches_litellm_marker_regex_and_retrieves() {
545 let _lock = crate::core::data_dir::test_env_lock();
546 let log = big_compressible_log();
547 let out = compress_tool_result_gateway(&log, Some("bash"));
548 assert!(out.len() < log.len(), "gateway funnel must still compress");
549
550 let litellm_regex = regex::Regex::new(r"hash=([a-f0-9]{24})").unwrap();
552 let captured = litellm_regex
553 .captures(&out)
554 .unwrap_or_else(|| panic!("gateway stub must carry a hash= marker: {out}"))
555 .get(1)
556 .unwrap()
557 .as_str();
558 assert_eq!(captured, ccr::litellm_hash(&log));
559
560 let recovered = ccr::retrieve_litellm(captured).expect("captured hash must resolve");
563 assert!(
564 recovered.contains("processed item 0007 ok")
565 && recovered.contains("processed item 0400 ok"),
566 "retrieve must return the verbatim original"
567 );
568
569 assert_eq!(out, compress_tool_result_gateway(&log, Some("bash")));
571
572 assert_eq!(
575 crate::core::protocol::strip_trailing_savings_footer(&out),
576 out
577 );
578 }
579
580 #[test]
581 fn gateway_stub_absent_for_passthrough_and_ctx_output() {
582 let _lock = crate::core::data_dir::test_env_lock();
583 assert!(!compress_tool_result_gateway("short output", Some("bash")).contains("hash="));
585 let raw = (1..=120)
588 .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
589 .collect::<Vec<_>>()
590 .join("\n");
591 let out = compress_tool_result_gateway(&raw, Some("ctx_shell"));
592 assert_eq!(out, raw);
593 }
594
595 #[test]
596 fn small_or_passthrough_output_gets_no_ccr_handle() {
597 let _lock = crate::core::data_dir::test_env_lock();
598 let tiny = "ok\n".repeat(10);
600 assert!(!compress_tool_result(&tiny, Some("bash")).contains("full original at"));
601 let raw = (1..=120)
603 .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
604 .collect::<Vec<_>>()
605 .join("\n");
606 let out = compress_tool_result(&raw, Some("ctx_shell"));
607 assert_eq!(out, raw, "lean-ctx tool result must stay verbatim (no CCR)");
608 }
609}