Skip to main content

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}