lean_ctx_server/tools/registered/
ctx_preload.rs1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
7use lean_ctx::tool_defs::tool_def;
8
9pub struct CtxPreloadTool;
10
11impl McpTool for CtxPreloadTool {
12 fn name(&self) -> &'static str {
13 "ctx_preload"
14 }
15
16 fn tool_def(&self) -> Tool {
17 tool_def(
18 "ctx_preload",
19 "Caches task-relevant files, returns L-curve-optimized summary.\n\
20 WORKFLOW: call at session start or when switching tasks, before ctx_read.\n\
21 ANTIPATTERN: not for reading individual files — use ctx_read instead.\n\
22 ~50-100 tokens vs ~5000 for individual reads (~50x savings).",
23 json!({
24 "type": "object",
25 "properties": {
26 "task": {
27 "type": "string",
28 "description": "Task description (short English)"
29 },
30 "path": {
31 "type": "string",
32 "description": "Project root"
33 }
34 },
35 "required": ["task"]
36 }),
37 )
38 }
39
40 fn handle(
41 &self,
42 args: &Map<String, Value>,
43 ctx: &ToolContext,
44 ) -> Result<ToolOutput, ErrorData> {
45 let task = get_str(args, "task").unwrap_or_default();
46
47 let resolved_path = if get_str(args, "path").is_some() {
48 if let Some(p) = ctx.resolved_path("path") {
49 Some(p.to_string())
50 } else if let Some(err) = ctx.path_error("path") {
51 return Err(ErrorData::invalid_params(format!("path: {err}"), None));
52 } else {
53 None
54 }
55 } else if let Some(ref session) = ctx.session {
56 let guard = crate::server::bounded_lock::read(session, "ctx_preload:session_root");
57 guard.as_ref().and_then(|g| g.project_root.clone())
58 } else {
59 None
60 };
61
62 let resolved_path = resolved_path.or_else(|| {
67 let root = ctx.project_root.trim();
68 (!root.is_empty()).then(|| root.to_string())
69 });
70
71 let cache = ctx
72 .cache
73 .as_ref()
74 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
75 let Some(mut cache_guard) = crate::server::bounded_lock::write(cache, "ctx_preload:cache")
76 else {
77 return Ok(ToolOutput::simple(
78 "[preload skipped — cache temporarily unavailable]".to_string(),
79 ));
80 };
81 let (mut result, _usable) = crate::tools::ctx_preload::handle(
82 &mut cache_guard,
83 &task,
84 resolved_path.as_deref(),
85 ctx.crp_mode,
86 );
87
88 let provider_hints = predict_and_prefetch(&task, &mut cache_guard, &ctx.project_root);
89 if !provider_hints.is_empty() {
90 result.push_str(&provider_hints);
91 }
92
93 drop(cache_guard);
94
95 if let Some(ref session_lock) = ctx.session {
96 if let Some(mut session_guard) =
97 crate::server::bounded_lock::write(session_lock, "ctx_preload:session_write")
98 && (session_guard.active_structured_intent.is_none()
99 || session_guard
100 .active_structured_intent
101 .as_ref()
102 .is_none_or(|i| i.confidence < 0.6))
103 {
104 session_guard.set_task(&task, Some("preload"));
105 }
106
107 if let Some(session_guard) =
108 crate::server::bounded_lock::read(session_lock, "ctx_preload:session_read")
109 && let Some(ref intent) = session_guard.active_structured_intent
110 && let Some(ref ledger_lock) = ctx.ledger
111 {
112 let Some(ledger) =
113 crate::server::bounded_lock::read(ledger_lock, "ctx_preload:ledger")
114 else {
115 return Ok(ToolOutput::simple(result));
116 };
117 if !ledger.entries.is_empty() {
118 let known: Vec<String> = session_guard
119 .files_touched
120 .iter()
121 .map(|f| f.path.clone())
122 .collect();
123 let deficit =
124 lean_ctx::core::context_deficit::detect_deficit(&ledger, intent, &known);
125 if !deficit.suggested_files.is_empty() {
126 result.push_str("\n\n--- SUGGESTED FILES ---");
127 for s in &deficit.suggested_files {
128 result.push_str(&format!(
129 "\n {} ({:?}, ~{} tok, mode: {})",
130 s.path, s.reason, s.estimated_tokens, s.recommended_mode
131 ));
132 }
133 }
134
135 let pressure = ledger.pressure();
136 if pressure.utilization > 0.7 {
137 let plan = ledger.reinjection_plan(intent, 0.6);
138 if !plan.actions.is_empty() {
139 result.push_str("\n\n--- REINJECTION PLAN ---");
140 result.push_str(&format!(
141 "\n Context pressure: {:.0}% -> target: 60%",
142 pressure.utilization * 100.0
143 ));
144 for a in &plan.actions {
145 result.push_str(&format!(
146 "\n {} : {} -> {} (frees ~{} tokens)",
147 a.path, a.current_mode, a.new_mode, a.tokens_freed
148 ));
149 }
150 result.push_str(&format!(
151 "\n Total freeable: {} tokens",
152 plan.total_tokens_freed
153 ));
154 }
155 }
156 }
157 }
158 }
159
160 Ok(ToolOutput {
161 text: result,
162 original_tokens: 0,
163 saved_tokens: 0,
164 mode: Some("preload".to_string()),
165 path: None,
166 changed: false,
167 shell_outcome: None,
168 content_blocks: None,
169 })
170 }
171}
172
173fn predict_and_prefetch(
178 task: &str,
179 cache: &mut lean_ctx::core::cache::SessionCache,
180 project_root: &str,
181) -> String {
182 let policy = lean_ctx::core::policy::runtime::active();
185 if !provider_prefetch_permitted(policy.as_deref())
186 || !lean_ctx::core::roles::active_role().is_tool_allowed("ctx_provider")
187 {
188 return String::new();
189 }
190 lean_ctx::core::providers::init::init_with_project_root(Some(std::path::Path::new(
191 project_root,
192 )));
193 let registry = lean_ctx::core::providers::registry::global_registry();
194 let available = registry.available_provider_ids();
195 if available.is_empty() {
196 return String::new();
197 }
198
199 let mut bandit = lean_ctx::core::provider_bandit::ProviderBandit::load(project_root);
200 let predictions =
201 lean_ctx::core::active_inference::predict_preloads(task, &available, &mut bandit, 2);
202
203 if predictions.is_empty() {
204 return String::new();
205 }
206 let task_type = lean_ctx::core::active_inference::infer_task_type(&task.to_lowercase());
207
208 let cfg = lean_ctx::core::config::Config::load();
209 let auto_index = cfg.providers.auto_index;
210 let mut all_artifacts = Vec::new();
211
212 let mut out = String::from("\n\n--- PROVIDER PRELOAD ---");
213 let mut prefetched = 0usize;
214
215 for pred in &predictions {
216 let params = lean_ctx::core::providers::provider_trait::ProviderParams {
217 limit: Some(5),
218 ..Default::default()
219 };
220
221 match registry.execute_as_chunks(&pred.provider_id, &pred.action, ¶ms) {
222 Ok(chunks) => {
223 bandit.update(&task_type, &pred.provider_id, !chunks.is_empty());
226 let artifacts = lean_ctx::core::consolidation::consolidate(&chunks);
227 for entry in &artifacts.cache_entries {
228 cache.store(&entry.uri, &entry.content);
229 prefetched += 1;
230 }
231 if auto_index && !artifacts.is_empty() {
232 all_artifacts.push(artifacts);
233 }
234 out.push_str(&format!(
235 "\n {} {} → {} items cached (confidence: {:.0}%)",
236 pred.provider_id,
237 pred.action,
238 chunks.len(),
239 pred.confidence * 100.0,
240 ));
241 }
242 Err(e) => {
243 bandit.update(&task_type, &pred.provider_id, false);
246 tracing::debug!(
247 "[preload] provider {}/{} failed: {e}",
248 pred.provider_id,
249 pred.action,
250 );
251 }
252 }
253 }
254
255 let _ = bandit.save(project_root);
258
259 if prefetched == 0 {
260 return String::new();
261 }
262
263 if !all_artifacts.is_empty() {
264 let root = project_root.to_string();
265 std::thread::spawn(move || {
266 let merged = merge_preload_artifacts(&all_artifacts);
267 crate::tools::ctx_provider::apply_artifacts_to_stores(&merged, &root);
268 });
269 }
270
271 out
272}
273
274fn provider_prefetch_permitted(
277 active: Option<&lean_ctx::core::policy::runtime::ActivePolicy>,
278) -> bool {
279 active.is_none_or(|policy| policy.tool_allowed("ctx_provider"))
280}
281
282fn merge_preload_artifacts(
283 all: &[lean_ctx::core::consolidation::ConsolidationArtifacts],
284) -> lean_ctx::core::consolidation::ConsolidationArtifacts {
285 let mut merged = lean_ctx::core::consolidation::ConsolidationArtifacts::default();
286 for a in all {
287 merged.bm25_chunks.extend(a.bm25_chunks.clone());
288 merged.edges.extend(a.edges.clone());
289 merged.facts.extend(a.facts.clone());
290 merged.cache_entries.extend(a.cache_entries.clone());
291 }
292 merged
293}
294
295#[cfg(test)]
296mod prefetch_policy_tests {
297 use super::provider_prefetch_permitted;
298 use lean_ctx::core::policy::runtime::ActivePolicy;
299
300 fn builtin(name: &str) -> ActivePolicy {
301 let pack = lean_ctx::core::policy::builtin::get(name).expect("built-in pack");
302 ActivePolicy::from_resolved(lean_ctx::core::policy::resolve(&pack).expect("resolves"))
303 }
304
305 fn denying(deny: &str) -> ActivePolicy {
308 let pack = lean_ctx::core::policy::parse(&format!(
309 "name = \"no-egress-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
310 extends = \"baseline\"\n\n[context]\ndeny_tools = [{deny}]\n"
311 ))
312 .expect("parses");
313 ActivePolicy::from_resolved(lean_ctx::core::policy::resolve(&pack).expect("resolves"))
314 }
315
316 #[test]
319 fn no_egress_packs_block_provider_prefetch() {
320 assert!(provider_prefetch_permitted(None));
321 let no_egress = denying(r#""ctx_url_read", "ctx_provider", "ctx_tools""#);
322 assert!(
323 !provider_prefetch_permitted(Some(&no_egress)),
324 "a no-egress pack must block provider prefetch"
325 );
326 assert!(provider_prefetch_permitted(Some(&denying(
327 r#""ctx_url_read""#
328 ))));
329 assert!(provider_prefetch_permitted(Some(&builtin("baseline"))));
330 }
331}