lean_ctx/core/gateway/postprocess/mod.rs
1//! Gateway output post-processor (deeper addon integration).
2//!
3//! The single seam where lean-ctx's own context-engineering is applied to
4//! *downstream* MCP tool output. Invoked once from [`super::proxy`], right after
5//! [`crate::core::addons::runtime::scrub_output`] has removed secrets — so
6//! everything here operates on already-sanitized text.
7//!
8//! Three independent, config-gated layers (all default off → pure pass-through):
9//! - **L1 compress** ([`compress`], #1093): format-aware shrink of the
10//! returned text to a token budget.
11//! - **L2 handle/spill** ([`spill`], #1094): oversized output → content-
12//! addressed archive + a `ctx_expand` handle instead of the full blob.
13//! - **L3 index** ([`index`], #1095): side-channel consolidation into BM25 /
14//! property graph / knowledge so the output is searchable later.
15//!
16//! Determinism (#498): L1/L2 are pure functions of (content, budget); L3 is a
17//! background side-channel that never touches the returned string.
18
19pub mod compress;
20pub mod index;
21pub mod spill;
22
23use super::adapters::{self, IntegrationKind};
24use super::config::{GatewayConfig, GatewayServer};
25
26/// Apply the configured output post-processing to one scrubbed downstream
27/// result, returning the (possibly transformed) text for the model.
28///
29/// `text` is the already-redacted downstream output, `server` owns the call,
30/// `tool` is the downstream tool name, and `project_root` scopes L3 indexing
31/// (empty = no project scope, so indexing is skipped).
32pub fn process(
33 cfg: &GatewayConfig,
34 server: &GatewayServer,
35 tool: &str,
36 text: String,
37 project_root: &str,
38) -> String {
39 let kind = IntegrationKind::parse(&server.integration);
40
41 // Fast path: no generic flags and no typed adapter → identity (legacy
42 // behaviour, zero cost).
43 if !cfg.postprocess_active() && kind.is_none() {
44 return text;
45 }
46
47 // Side-channel ingestion: index the *full* output before any model-facing
48 // truncation, on a background thread. A typed adapter (graph/symbols/memory)
49 // claims it; otherwise the generic L3 indexer runs. Never alters `text`.
50 if cfg.index_output && !project_root.is_empty() && !text.trim().is_empty() {
51 let claimed = adapters::ingest_spawn(kind, &server.name, tool, &text, project_root);
52 if !claimed {
53 index::spawn(
54 server.name.clone(),
55 tool.to_string(),
56 text.clone(),
57 project_root.to_string(),
58 );
59 }
60 }
61
62 let budget = cfg.effective_output_budget();
63
64 // L4 model-facing transform (e.g. codebase-pack → retrieval handle). Runs
65 // whenever the integration is configured, independent of the generic flags.
66 if let Some(transformed) = adapters::transform(kind, &server.name, tool, &text, budget) {
67 return transformed;
68 }
69
70 // L2: oversized output → spill verbatim + return a retrieval handle. Takes
71 // precedence over L1 (a handle is already minimal; no point compressing it).
72 if cfg.handle_spill
73 && let Some(handle) = spill::maybe_spill(&server.name, tool, &text, budget)
74 {
75 return handle;
76 }
77
78 // L1: format-aware compression to the token budget.
79 if cfg.compress_output {
80 return compress::to_budget(&text, budget);
81 }
82
83 text
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use crate::core::gateway::config::GatewayServer;
90 use std::fmt::Write as _;
91
92 fn server(name: &str) -> GatewayServer {
93 GatewayServer {
94 name: name.into(),
95 command: "x".into(),
96 ..Default::default()
97 }
98 }
99
100 #[test]
101 fn all_flags_off_is_identity() {
102 let cfg = GatewayConfig::default();
103 let big = "line\n".repeat(5000);
104 let out = process(&cfg, &server("s"), "t", big.clone(), "");
105 assert_eq!(out, big, "default config must be a pure pass-through");
106 }
107
108 #[test]
109 fn compress_flag_shrinks_oversized_output() {
110 let cfg = GatewayConfig {
111 compress_output: true,
112 output_budget_tokens: 256,
113 ..Default::default()
114 };
115 // Distinct lines so entropy compression has something to rank + drop.
116 let big = (0..4000).fold(String::new(), |mut s, i| {
117 let _ = writeln!(s, "item number {i} value");
118 s
119 });
120 let out = process(&cfg, &server("s"), "t", big.clone(), "");
121 assert!(out.len() < big.len(), "compress_output must reduce size");
122 }
123}