Skip to main content

lean_ctx_server/tools/registered/
ctx_preload.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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        // Never let `handle` fall back to "." (the daemon CWD, which is not the
63        // project): resolve against the dispatch-provided root so graph-relative
64        // preload candidates (e.g. `rust/src/core/foo.rs`) jail against the real
65        // project root in every IDE, even when no explicit `path` was passed.
66        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
173/// Use Active Inference to predict useful provider data and prefetch it.
174/// Stores results in session cache (synchronous) and triggers deep
175/// indexing (BM25, Graph, Knowledge) in a background thread when
176/// `providers.auto_index` is enabled.
177fn predict_and_prefetch(
178    task: &str,
179    cache: &mut lean_ctx::core::cache::SessionCache,
180    project_root: &str,
181) -> String {
182    // GHSA-jhf9-3pv4-m469: prefetch is provider egress without a model tool
183    // call, so it must honour the same gate as an explicit `ctx_provider`.
184    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, &params) {
222            Ok(chunks) => {
223                // Active-inference feedback: a provider that actually returned
224                // context for this task type is a positive prediction error.
225                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                // A failed/empty provider is a negative prediction error — learn
244                // not to bet on it for this task type next time.
245                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    // Persist the learning even when nothing prefetched — negative outcomes are
256    // exactly what we want the bandit to remember.
257    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
274/// Provider prefetch is allowed only when the active policy pack (if any)
275/// permits `ctx_provider`.
276fn 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    /// A user-authored pack denying `deny` (the regulated packs ship with the
306    /// paid intelligence packs on this line).
307    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    /// GHSA-jhf9-3pv4-m469: packs that say "no web fetches" stop the
317    /// implicit provider prefetch, permissive packs keep it.
318    #[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}