lean_ctx/core/gateway/adapters/
compression.rs1use 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
27pub 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
51pub 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
66fn 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 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
102fn 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
120fn 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 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}