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