Skip to main content

lean_ctx/proxy/
compress.rs

1use super::ccr;
2use crate::core::tokens::count_tokens;
3use crate::core::web::distill;
4
5/// Byte-ish budget for the research-prose squeeze (~5k tokens on English prose).
6/// Only oversized prose is truncated; the squeeze's main job is dedup + blank-collapse,
7/// not cutting.
8const 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
19/// Proxy compression funnel: routes a tool result to the right compressor.
20///
21/// 1. Already-cited research output (from `ctx_url_read` / the web layer) is kept
22///    verbatim — it is distilled and citation-stamped, so the shell pipeline must
23///    not touch its footer or claim markers.
24/// 2. Prose results (web fetches, doc reads, research MCP bridges) are squeezed
25///    by the prose-aware research compressor instead of the log/code-tuned shell
26///    engine.
27/// 3. Everything else (shell/build/search output) flows through the unified
28///    `compress_if_beneficial` pipeline. A `$ ...` command hint is extracted so
29///    the pattern engine gets the same routing as the CLI and MCP paths.
30pub fn compress_tool_result(content: &str, tool_name: Option<&str>) -> String {
31    let compressed = compress_inner(content, tool_name);
32    attach_ccr(content, compressed)
33}
34
35/// Make a live-compressed `tool_result` non-lossy (#482): when compression
36/// removed a meaningful amount, tee the verbatim original to the shared
37/// content-addressed store and append a deterministic recovery handle. The
38/// handle is a pure function of the content hash, so the rewritten result is
39/// byte-stable across turns and never invalidates the provider cache prefix
40/// (#448). Passthrough / verbatim results (no real shrink) keep their bytes.
41fn attach_ccr(original: &str, result: String) -> String {
42    if original.len() < ccr::MIN_TEE_BYTES
43        || original.len().saturating_sub(result.len()) < ccr::MIN_TEE_BYTES
44    {
45        return result;
46    }
47    match ccr::persist(original) {
48        Some(handle) => match ccr::inband_locator(&handle) {
49            // In-band (#493): a remote agent can't read the local tee path, so
50            // advertise the echo-able marker instead — echoing it splices the
51            // verbatim original back inline next turn.
52            Some(marker) => format!(
53                "{result}\n[lean-ctx: full original elided to save tokens — echo {marker} \
54                 on your next turn to get the verbatim original spliced back inline]"
55            ),
56            // Shared-filesystem default: the path handle (native read or slice).
57            None => format!(
58                "{result}\n[lean-ctx: full original at {handle} — read it, or \
59                 ctx_expand(id=\"{handle}\", head=N|search=\"…\"|json_path=\"…\") for a slice]"
60            ),
61        },
62        None => result,
63    }
64}
65
66fn compress_inner(content: &str, tool_name: Option<&str>) -> String {
67    if content.trim().is_empty() || content.len() < 200 {
68        return content.to_string();
69    }
70
71    // #479: lean-ctx's own MCP tools already applied their compression policy at
72    // the tool boundary — honouring `raw=true`/`bypass`, `<lc_safe>` spans and
73    // the configured aggressiveness. Their `raw` intent lives in the originating
74    // `tool_use` input, which is invisible here, so re-compressing on the wire
75    // would silently undo an explicit `raw=true` and double-compress everything
76    // else. Pass results from `ctx_*` tools through untouched.
77    if tool_name.is_some_and(is_lean_ctx_tool) {
78        return content.to_string();
79    }
80
81    // #709: honour explicit <lc_safe>…</lc_safe> spans on the proxy path too.
82    // Protected spans pass through verbatim; each unprotected segment flows back
83    // through the normal funnel (markers are stripped, so this never recurses).
84    if crate::core::protect::has_markers(content) {
85        return crate::core::protect::compress_preserving(content, |seg| {
86            compress_inner(seg, tool_name)
87        });
88    }
89
90    if is_cited_research_output(content) {
91        return content.to_string();
92    }
93
94    if extract_command_hint(content).is_none()
95        && looks_like_prose(content)
96        && let Some(out) = squeeze_research_prose(content)
97    {
98        return out;
99    }
100
101    let cmd = infer_command(content, tool_name);
102
103    // Proxy fidelity guard. A foreign shell tool gives us at most a generic
104    // command (`"shell"`) or none, so the engine's command-gated build/test
105    // verbatim guards never fire. When the *output* is unmistakably a build or
106    // test run, preserve it verbatim (bounded by safety-line-preserving
107    // truncation) so compiler errors, panics and test summaries reach the model
108    // intact — the exact signal a bug-fix task depends on.
109    let generic_command = cmd.is_empty() || cmd == "shell";
110    if generic_command
111        && (output_looks_like_test_run(content) || output_looks_like_build_failure(content))
112    {
113        return crate::shell::compress::engine::preserve_verbatim_pub(content);
114    }
115
116    crate::shell::compress::engine::compress_if_beneficial(&cmd, content)
117}
118
119/// Strong, ecosystem-spanning signals that an output is a *test run* (passing or
120/// failing). Conservative — matches the summary/result lines a bug-fix task must
121/// never lose. Only consulted on the proxy path when the real command is unknown.
122fn output_looks_like_test_run(content: &str) -> bool {
123    const NEEDLES: &[&str] = &[
124        "test result:",            // rust
125        "short test summary info", // pytest
126        " passed in ",             // pytest summary
127        " failed in ",             // pytest summary
128        "=== RUN",                 // go
129        "--- FAIL:",               // go
130        "--- PASS:",               // go
131        "Test Suites:",            // jest
132        " examples, ",             // rspec ("5 examples, 0 failures")
133        "FAILED",                  // generic test failure marker
134    ];
135    NEEDLES.iter().any(|n| content.contains(n))
136}
137
138/// Strong, specific signals of a build / compile / runtime failure across the
139/// major toolchains. Used only on the proxy path for generically-named tools so
140/// the failing diagnostics (paths, lines, messages) survive intact.
141fn output_looks_like_build_failure(content: &str) -> bool {
142    const NEEDLES: &[&str] = &[
143        "error[",                            // rustc (E0277 …)
144        ": error:",                          // gcc / clang "file.c:12:5: error:"
145        "fatal error:",                      // gcc / clang
146        "undefined reference to",            // linker
147        "panicked at",                       // rust runtime
148        "could not compile",                 // cargo
149        "Traceback (most recent call last)", // python
150        "AssertionError",                    // python / junit
151        "make: ***",                         // make
152        "Build FAILED",
153        "BUILD FAILED",
154        "Segmentation fault",
155    ];
156    NEEDLES.iter().any(|n| content.contains(n))
157}
158
159/// True when `content` is a lean-ctx web read: distilled body + citation footer
160/// (`Source: …\nSite: … · Retrieved: …`). Such output is re-compression-hostile.
161fn is_cited_research_output(content: &str) -> bool {
162    content.contains("· Retrieved: ") && content.contains("\nSource: ")
163}
164
165/// Code/shell symbols whose density cleanly separates source/logs from prose.
166const CODE_SYMBOLS: &str = "{}<>;=|\\$`";
167
168/// Conservative prose detector: substantial, letter-dense, low code-symbol, with
169/// real sentences and long lines. Code, logs, tables and JSON all fail this.
170fn looks_like_prose(content: &str) -> bool {
171    let sample: String = content.chars().take(4000).collect();
172    let total = sample.chars().count();
173    if total < 600 {
174        return false;
175    }
176    let total_f = total as f32;
177    let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
178    let spaces = sample.chars().filter(|c| *c == ' ').count() as f32;
179    let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
180
181    if alpha / total_f < 0.6 || spaces / total_f < 0.12 || symbols / total_f > 0.06 {
182        return false;
183    }
184    if sample.matches(['.', '!', '?']).count() < 4 {
185        return false;
186    }
187
188    let non_empty: Vec<&str> = sample.lines().filter(|l| !l.trim().is_empty()).collect();
189    if non_empty.is_empty() {
190        return false;
191    }
192    let avg_len =
193        non_empty.iter().map(|l| l.chars().count()).sum::<usize>() as f32 / non_empty.len() as f32;
194    avg_len >= 40.0
195}
196
197/// Apply the prose squeeze, returning a footer-stamped result only when it
198/// actually saves tokens; otherwise `None` so the normal pipeline can try.
199fn squeeze_research_prose(content: &str) -> Option<String> {
200    let before = count_tokens(content);
201    let squeezed = squeeze_research_prose_body(content);
202    if squeezed.trim().is_empty() {
203        return None;
204    }
205    let after = count_tokens(&squeezed);
206    if after + 2 >= before {
207        return None;
208    }
209    Some(crate::core::protocol::append_savings_with_info(
210        &squeezed,
211        before,
212        after,
213        Some("research"),
214        None,
215    ))
216}
217
218/// Choose the prose-squeeze body. Only when the content would actually be
219/// TRUNCATED (over the cap) do we upgrade from FIFO prefix truncation to
220/// extractive centrality ranking — which keeps the most representative sentences
221/// instead of just the first ones — via the cache-safe, memoized wire squeeze
222/// ([`crate::proxy::prose_ranker`]), so the cold→warm engine transition never
223/// changes a frozen-region rewrite (#448/#498). Below the cap the squeeze is a
224/// lossless dedup pass, so the cheaper truncating squeeze is used.
225fn squeeze_research_prose_body(content: &str) -> String {
226    let cap = research_prose_cap();
227    if content.len() > cap {
228        return super::prose_ranker::squeeze(content, cap);
229    }
230    distill::squeeze_prose(content, cap)
231}
232
233/// True when `name` refers to one of lean-ctx's own `ctx_*` MCP tools, whose
234/// results are already compressed at the tool boundary and must not be touched
235/// again by the proxy (#479).
236///
237/// Clients namespace MCP tools differently, so a plain `starts_with("ctx_")`
238/// misses the real-world callers: Claude Code (the reporter's setup) sends
239/// `mcp__lean-ctx__ctx_shell`, others use `lean-ctx:ctx_read`. Strip the client
240/// prefix down to the bare tool segment before matching.
241fn is_lean_ctx_tool(name: &str) -> bool {
242    let bare = name
243        .rsplit("__")
244        .next()
245        .unwrap_or(name)
246        .rsplit([':', '/', '.'])
247        .next()
248        .unwrap_or(name);
249    bare.starts_with("ctx_") || name.starts_with("ctx_")
250}
251
252fn infer_command(content: &str, tool_name: Option<&str>) -> String {
253    if let Some(cmd) = extract_command_hint(content) {
254        return cmd;
255    }
256
257    if let Some(name) = tool_name {
258        let nl = name.to_lowercase();
259        if nl.contains("bash") || nl.contains("shell") || nl.contains("terminal") {
260            return "shell".to_string();
261        }
262        if nl.contains("search") || nl.contains("grep") || nl.contains("find") {
263            return "grep".to_string();
264        }
265    }
266
267    String::new()
268}
269
270fn extract_command_hint(content: &str) -> Option<String> {
271    for line in content.lines().take(3) {
272        let trimmed = line.trim();
273        if let Some(cmd) = trimmed.strip_prefix("$ ") {
274            return Some(cmd.to_string());
275        }
276        if let Some(cmd) = trimmed.strip_prefix("% ") {
277            return Some(cmd.to_string());
278        }
279    }
280    None
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use serial_test::serial;
287
288    #[test]
289    fn short_content_unchanged() {
290        let short = "hello world";
291        assert_eq!(compress_tool_result(short, None), short);
292    }
293
294    #[test]
295    fn empty_content_unchanged() {
296        assert_eq!(compress_tool_result("", None), "");
297        assert_eq!(compress_tool_result("   ", None), "   ");
298    }
299
300    #[test]
301    fn command_hint_extraction() {
302        assert_eq!(
303            extract_command_hint("$ cargo build\nCompiling foo"),
304            Some("cargo build".to_string())
305        );
306        assert_eq!(extract_command_hint("no prefix here"), None);
307    }
308
309    #[test]
310    fn tool_name_inference() {
311        assert_eq!(infer_command("some text", Some("bash_execute")), "shell");
312        assert_eq!(infer_command("some text", Some("search_files")), "grep");
313        assert_eq!(infer_command("some text", Some("unknown_tool")), "");
314    }
315
316    #[test]
317    fn lean_ctx_tool_results_pass_through_verbatim() {
318        // A ctx_shell result the tool already produced (raw=true, or its own
319        // compression). The proxy must NOT re-compress / re-truncate it — that
320        // was the #479 defect where `raw=true` was silently undone on the wire.
321        let raw = (1..=120)
322            .map(|i| format!("Line {i:04}: the quick brown fox jumps over the lazy dog"))
323            .collect::<Vec<_>>()
324            .join("\n");
325        assert!(raw.len() > 200);
326        // Bare names AND the namespaced forms real MCP clients emit (Claude Code
327        // `mcp__lean-ctx__ctx_shell`, colon-style `lean-ctx:ctx_read`).
328        for tool in [
329            "ctx_shell",
330            "ctx_read",
331            "ctx_search",
332            "ctx_grep",
333            "mcp__lean-ctx__ctx_shell",
334            "lean-ctx:ctx_read",
335        ] {
336            assert_eq!(
337                compress_tool_result(&raw, Some(tool)),
338                raw,
339                "{tool} output must pass through the proxy verbatim"
340            );
341        }
342        // A foreign tool with identical output is still compressed: the proxy
343        // keeps adding value for non-lean-ctx tools.
344        assert_ne!(
345            compress_tool_result(&raw, Some("bash")),
346            raw,
347            "foreign-tool output should still be compressed by the proxy"
348        );
349    }
350
351    #[test]
352    fn cited_research_output_is_preserved_verbatim() {
353        let cited = format!(
354            "Rust is a language.\n\n---\nSource: Rust — https://x.com/a\n\
355             Site: x.com · Retrieved: 2026-06-06T00:00:00Z\n{}",
356            "Extra body line that would otherwise be touched. ".repeat(20)
357        );
358        assert_eq!(compress_tool_result(&cited, Some("ctx_url_read")), cited);
359    }
360
361    #[test]
362    fn prose_is_squeezed_and_deduped() {
363        let para = "Rust is a multi-paradigm systems programming language that \
364                    emphasizes performance, type safety, and fearless concurrency, \
365                    achieving memory safety without a garbage collector at runtime.";
366        // Repeated paragraph (well over the 600-char prose floor) → dedup keeps one.
367        let input = format!("{}\n", [para; 8].join("\n\n"));
368        assert!(input.len() > 600);
369        let out = compress_tool_result(&input, Some("web_fetch"));
370        assert_eq!(out.matches("fearless concurrency").count(), 1);
371        assert!(out.contains("performance, type safety"));
372    }
373
374    #[test]
375    #[serial]
376    fn research_prose_cap_env_overrides_default() {
377        let _lock = crate::core::data_dir::test_env_lock();
378        crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, "1234");
379        assert_eq!(research_prose_cap(), 1234);
380        crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
381    }
382
383    #[test]
384    #[serial]
385    fn research_prose_cap_env_invalid_falls_back() {
386        let _lock = crate::core::data_dir::test_env_lock();
387        for value in ["", "not_a_number", "0"] {
388            crate::test_env::set_var(RESEARCH_PROSE_CAP_ENV, value);
389            assert_eq!(research_prose_cap(), RESEARCH_PROSE_CAP);
390        }
391        crate::test_env::remove_var(RESEARCH_PROSE_CAP_ENV);
392    }
393
394    #[test]
395    fn code_output_is_not_treated_as_prose() {
396        let code = "fn main() {\n    let x = vec![1, 2, 3];\n    \
397                    for i in &x { println!(\"{}\", i); }\n}\n"
398            .repeat(20);
399        assert!(!looks_like_prose(&code));
400    }
401
402    #[test]
403    fn shell_log_is_not_treated_as_prose() {
404        let log = "$ cargo build\n   Compiling foo v0.1.0\n    Finished dev\n".repeat(20);
405        assert!(!looks_like_prose(&log));
406    }
407
408    #[test]
409    fn foreign_shell_build_failure_preserved_verbatim() {
410        // A forge/pi-style shell tool: the name says "shell" and the output has
411        // no `$ cmd` hint, so the engine's command-gated guards cannot fire. The
412        // compiler error must still reach the model intact for a bug-fix task.
413        let mut log = String::from("gcc -O2 -c src/versioncmp.c -o versioncmp.o\n");
414        log.push_str("src/versioncmp.c: In function 'version_cmp':\n");
415        log.push_str(
416            "src/versioncmp.c:142:17: error: invalid operands to binary < (have 'char *' and 'int')\n",
417        );
418        for i in 0..40 {
419            log.push_str(&format!("  note: expansion context line {i}\n"));
420        }
421        log.push_str("make: *** [Makefile:23: versioncmp.o] Error 1\n");
422
423        let out = compress_tool_result(&log, Some("shell"));
424        assert!(
425            out.contains("versioncmp.c:142:17: error:"),
426            "compiler error must survive the proxy"
427        );
428        assert!(
429            out.contains("make: ***"),
430            "make failure summary must survive"
431        );
432    }
433
434    #[test]
435    fn foreign_shell_test_failure_preserved_verbatim() {
436        let mut log = String::from("running 3 tests\n");
437        log.push_str("test version::tests::sorts_numeric ... FAILED\n");
438        for i in 0..40 {
439            log.push_str(&format!("note line {i} with some filler content here\n"));
440        }
441        log.push_str("test result: FAILED. 2 passed; 1 failed; 0 ignored\n");
442
443        let out = compress_tool_result(&log, Some("bash"));
444        assert!(
445            out.contains("test result: FAILED"),
446            "test summary must survive the proxy"
447        );
448        assert!(out.contains("sorts_numeric ... FAILED"));
449    }
450
451    #[test]
452    fn plain_shell_log_not_forced_verbatim() {
453        let log = "Listening on port 8080\nRequest received from 10.0.0.2\n".repeat(20);
454        assert!(!output_looks_like_test_run(&log));
455        assert!(!output_looks_like_build_failure(&log));
456    }
457
458    fn big_compressible_log() -> String {
459        (1..=400)
460            .map(|i| format!("[info] processed item {i:04} ok"))
461            .collect::<Vec<_>>()
462            .join("\n")
463    }
464
465    #[test]
466    fn live_compression_is_recoverable_via_ccr_handle() {
467        let _lock = crate::core::data_dir::test_env_lock();
468        let log = big_compressible_log();
469        let out = compress_tool_result(&log, Some("bash"));
470        assert!(
471            out.len() < log.len(),
472            "a large foreign log must be compressed"
473        );
474
475        // The compressed result carries the content-addressed handle, and the
476        // handle points at the *verbatim* original — live compression is now
477        // non-lossy (#482), recoverable with a plain native file read.
478        let handle = ccr::persist(&log).expect("same content -> same handle");
479        assert!(out.contains(&handle), "CCR handle must be embedded: {out}");
480        let recovered = std::fs::read_to_string(&handle).expect("tee file readable");
481        assert!(
482            recovered.contains("processed item 0007 ok")
483                && recovered.contains("processed item 0400 ok"),
484            "verbatim original must be fully recoverable"
485        );
486    }
487
488    #[test]
489    fn live_compression_output_is_byte_stable_across_turns() {
490        let _lock = crate::core::data_dir::test_env_lock();
491        let log = big_compressible_log();
492        let a = compress_tool_result(&log, Some("bash"));
493        let b = compress_tool_result(&log, Some("bash"));
494        assert_eq!(
495            a, b,
496            "the CCR handle is content-addressed, so the rewritten result must be \
497             byte-identical across turns (provider cache prefix stays valid, #448)"
498        );
499    }
500
501    #[test]
502    fn small_or_passthrough_output_gets_no_ccr_handle() {
503        let _lock = crate::core::data_dir::test_env_lock();
504        // Below the 200-char compress floor: passes through, no handle.
505        let tiny = "ok\n".repeat(10);
506        assert!(!compress_tool_result(&tiny, Some("bash")).contains("full original at"));
507        // lean-ctx tool output passes through verbatim (no handle either).
508        let raw = (1..=120)
509            .map(|i| format!("Line {i:04}: lorem ipsum dolor sit amet consectetur"))
510            .collect::<Vec<_>>()
511            .join("\n");
512        let out = compress_tool_result(&raw, Some("ctx_shell"));
513        assert_eq!(out, raw, "lean-ctx tool result must stay verbatim (no CCR)");
514    }
515}