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