Skip to main content

lean_ctx/core/gateway/adapters/
compression.rs

1//! compression adapter (#1101): a downstream compression addon (Headroom / RTK)
2//! exposed as a *named* lean-ctx `Compressor` in the extension registry — so it
3//! is discoverable via `/v1/capabilities` and selectable by name, exactly like
4//! the built-in `identity`/`prose`/`markdown` compressors.
5//!
6//! Positioning (counter, not lock-in): the addon plugs into lean-ctx as one
7//! interchangeable compressor among many, and — because every gateway result
8//! flows through the L2 spill path — lean-ctx stays the single retrieval layer
9//! (`ctx_expand`). The addon compresses; lean-ctx owns retrieval.
10//!
11//! Calling a network MCP server from the sync `Compressor::compress` trait is
12//! done on a dedicated thread+runtime (`run_blocking`), so it is safe from any
13//! caller context and never blocks an ambient runtime. Any failure degrades to
14//! returning the input unchanged.
15
16use std::sync::{Arc, Once};
17use std::time::Duration;
18
19use serde_json::{Map, Value, json};
20
21use super::super::client;
22use super::super::config::GatewayConfig;
23use super::IntegrationKind;
24use crate::core::config::Config;
25use crate::core::extension_registry::{Compressor, global};
26
27/// A downstream compression addon presented as a lean-ctx compressor.
28pub struct GatewayCompressor {
29    server: String,
30}
31
32impl GatewayCompressor {
33    #[must_use]
34    pub fn new(server: impl Into<String>) -> Self {
35        Self {
36            server: server.into(),
37        }
38    }
39}
40
41impl Compressor for GatewayCompressor {
42    fn name(&self) -> &str {
43        &self.server
44    }
45
46    fn compress(&self, input: &str, _budget: Option<usize>) -> String {
47        try_compress(&self.server, input).unwrap_or_else(|| input.to_string())
48    }
49}
50
51/// Register every compression-integration server in `cfg` as a named compressor
52/// in the global extension registry. Idempotent; runs at most once per process.
53pub fn ensure_registered(cfg: &GatewayConfig) {
54    static ONCE: Once = Once::new();
55    ONCE.call_once(|| {
56        for s in cfg.active_servers() {
57            if IntegrationKind::parse(&s.integration) == IntegrationKind::Compression
58                && let Ok(mut reg) = global().write()
59            {
60                reg.register_compressor(Arc::new(GatewayCompressor::new(s.name.clone())));
61            }
62        }
63    });
64}
65
66/// Route `input` through the server's compression tool via the gateway. Returns
67/// `None` (caller keeps the input) when the gateway is off, the server is gone,
68/// it exposes no usable tool, or the call fails.
69fn try_compress(server: &str, input: &str) -> Option<String> {
70    let cfg = Config::load();
71    let gw = cfg.gateway;
72    if !gw.enabled_effective() {
73        return None;
74    }
75    let srv = gw.active_servers().find(|s| s.name == server)?;
76    let transport = srv.resolve().ok()?;
77    let timeout = Duration::from_secs(gw.call_timeout_secs.max(1));
78    let input = input.to_string();
79    let server = server.to_string();
80
81    run_blocking(async move {
82        let tools = client::fetch_tools(&transport, timeout).await.ok()?;
83        // Prefer an explicit compression tool; fall back to the server's first
84        // tool so any single-tool compression server works out of the box.
85        let tool = tools
86            .iter()
87            .find(|t| t.name.to_lowercase().contains("compress"))
88            .or_else(|| tools.first())?;
89        let arg = first_string_param(tool)?;
90        let mut args = Map::new();
91        args.insert(arg, json!(input));
92        let result = client::proxy_call(&transport, &tool.name, args, timeout)
93            .await
94            .ok()?;
95        Some(crate::core::addons::runtime::scrub_output(
96            &server,
97            &client::result_to_text(&result),
98        ))
99    })
100}
101
102/// The input-text parameter of a compression tool: a known text-ish name if
103/// present, else the first string-typed property, else the first property.
104fn first_string_param(tool: &rmcp::model::Tool) -> Option<String> {
105    let props = tool.input_schema.get("properties")?.as_object()?;
106    const PREFERRED: [&str; 6] = ["text", "input", "content", "code", "message", "data"];
107    for name in PREFERRED {
108        if props.contains_key(name) {
109            return Some(name.to_string());
110        }
111    }
112    for (key, schema) in props {
113        if schema.get("type").and_then(Value::as_str) == Some("string") {
114            return Some(key.clone());
115        }
116    }
117    props.keys().next().cloned()
118}
119
120/// Run a future to completion on a dedicated thread + current-thread runtime, so
121/// the sync `compress` works whether or not the caller is inside a runtime.
122fn run_blocking<T, F>(fut: F) -> T
123where
124    T: Send + 'static,
125    F: std::future::Future<Output = T> + Send + 'static,
126{
127    std::thread::scope(|s| {
128        s.spawn(|| {
129            tokio::runtime::Builder::new_current_thread()
130                .enable_all()
131                .build()
132                .expect("compressor runtime")
133                .block_on(fut)
134        })
135        .join()
136        .expect("compressor thread")
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn name_is_the_server() {
146        let c = GatewayCompressor::new("headroom");
147        assert_eq!(c.name(), "headroom");
148    }
149
150    #[test]
151    fn compress_is_graceful_when_gateway_disabled() {
152        crate::test_env::remove_var("LEAN_CTX_GATEWAY");
153        // No gateway configured in the test env → input returned unchanged.
154        let c = GatewayCompressor::new("nonexistent-server");
155        let input = "some text to compress";
156        assert_eq!(c.compress(input, None), input);
157    }
158
159    #[test]
160    fn first_string_param_prefers_known_names() {
161        let tool = crate::tool_defs::tool_def(
162            "compress_text",
163            "desc",
164            json!({
165                "type": "object",
166                "properties": { "level": {"type":"integer"}, "text": {"type":"string"} }
167            }),
168        );
169        assert_eq!(first_string_param(&tool).as_deref(), Some("text"));
170    }
171}