Skip to main content

lean_ctx/tools/registered/
ctx_preload.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
6use crate::tool_defs::tool_def;
7
8pub struct CtxPreloadTool;
9
10impl McpTool for CtxPreloadTool {
11    fn name(&self) -> &'static str {
12        "ctx_preload"
13    }
14
15    fn tool_def(&self) -> Tool {
16        tool_def(
17            "ctx_preload",
18            "Caches task-relevant files, returns L-curve-optimized summary.\n\
19             WORKFLOW: call at session start or when switching tasks, before ctx_read.\n\
20             ANTIPATTERN: not for reading individual files — use ctx_read instead.\n\
21             ~50-100 tokens vs ~5000 for individual reads (~50x savings).",
22            json!({
23                "type": "object",
24                "properties": {
25                    "task": {
26                        "type": "string",
27                        "description": "Task description (short English)"
28                    },
29                    "path": {
30                        "type": "string",
31                        "description": "Project root"
32                    }
33                },
34                "required": ["task"]
35            }),
36        )
37    }
38
39    fn handle(
40        &self,
41        args: &Map<String, Value>,
42        ctx: &ToolContext,
43    ) -> Result<ToolOutput, ErrorData> {
44        let task = get_str(args, "task").unwrap_or_default();
45
46        let resolved_path = if get_str(args, "path").is_some() {
47            if let Some(p) = ctx.resolved_path("path") {
48                Some(p.to_string())
49            } else if let Some(err) = ctx.path_error("path") {
50                return Err(ErrorData::invalid_params(format!("path: {err}"), None));
51            } else {
52                None
53            }
54        } else if let Some(ref session) = ctx.session {
55            let guard = crate::server::bounded_lock::read(session, "ctx_preload:session_root");
56            guard.as_ref().and_then(|g| g.project_root.clone())
57        } else {
58            None
59        };
60
61        // Never let `handle` fall back to "." (the daemon CWD, which is not the
62        // project): resolve against the dispatch-provided root so graph-relative
63        // preload candidates (e.g. `rust/src/core/foo.rs`) jail against the real
64        // project root in every IDE, even when no explicit `path` was passed.
65        let resolved_path = resolved_path.or_else(|| {
66            let root = ctx.project_root.trim();
67            (!root.is_empty()).then(|| root.to_string())
68        });
69
70        let cache = ctx
71            .cache
72            .as_ref()
73            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
74        let Some(mut cache_guard) = crate::server::bounded_lock::write(cache, "ctx_preload:cache")
75        else {
76            return Ok(ToolOutput::simple(
77                "[preload skipped — cache temporarily unavailable]".to_string(),
78            ));
79        };
80        let (mut result, _usable) = crate::tools::ctx_preload::handle(
81            &mut cache_guard,
82            &task,
83            resolved_path.as_deref(),
84            ctx.crp_mode,
85        );
86
87        let provider_hints = predict_and_prefetch(&task, &mut cache_guard, &ctx.project_root);
88        if !provider_hints.is_empty() {
89            result.push_str(&provider_hints);
90        }
91
92        drop(cache_guard);
93
94        if let Some(ref session_lock) = ctx.session {
95            if let Some(mut session_guard) =
96                crate::server::bounded_lock::write(session_lock, "ctx_preload:session_write")
97                && (session_guard.active_structured_intent.is_none()
98                    || session_guard
99                        .active_structured_intent
100                        .as_ref()
101                        .is_none_or(|i| i.confidence < 0.6))
102            {
103                session_guard.set_task(&task, Some("preload"));
104            }
105
106            if let Some(session_guard) =
107                crate::server::bounded_lock::read(session_lock, "ctx_preload:session_read")
108                && let Some(ref intent) = session_guard.active_structured_intent
109                && let Some(ref ledger_lock) = ctx.ledger
110            {
111                let Some(ledger) =
112                    crate::server::bounded_lock::read(ledger_lock, "ctx_preload:ledger")
113                else {
114                    return Ok(ToolOutput::simple(result));
115                };
116                if !ledger.entries.is_empty() {
117                    let known: Vec<String> = session_guard
118                        .files_touched
119                        .iter()
120                        .map(|f| f.path.clone())
121                        .collect();
122                    let deficit =
123                        crate::core::context_deficit::detect_deficit(&ledger, intent, &known);
124                    if !deficit.suggested_files.is_empty() {
125                        result.push_str("\n\n--- SUGGESTED FILES ---");
126                        for s in &deficit.suggested_files {
127                            result.push_str(&format!(
128                                "\n  {} ({:?}, ~{} tok, mode: {})",
129                                s.path, s.reason, s.estimated_tokens, s.recommended_mode
130                            ));
131                        }
132                    }
133
134                    let pressure = ledger.pressure();
135                    if pressure.utilization > 0.7 {
136                        let plan = ledger.reinjection_plan(intent, 0.6);
137                        if !plan.actions.is_empty() {
138                            result.push_str("\n\n--- REINJECTION PLAN ---");
139                            result.push_str(&format!(
140                                "\n  Context pressure: {:.0}% -> target: 60%",
141                                pressure.utilization * 100.0
142                            ));
143                            for a in &plan.actions {
144                                result.push_str(&format!(
145                                    "\n  {} : {} -> {} (frees ~{} tokens)",
146                                    a.path, a.current_mode, a.new_mode, a.tokens_freed
147                                ));
148                            }
149                            result.push_str(&format!(
150                                "\n  Total freeable: {} tokens",
151                                plan.total_tokens_freed
152                            ));
153                        }
154                    }
155                }
156            }
157        }
158
159        Ok(ToolOutput {
160            text: result,
161            original_tokens: 0,
162            saved_tokens: 0,
163            mode: Some("preload".to_string()),
164            path: None,
165            changed: false,
166            shell_outcome: None,
167            content_blocks: None,
168        })
169    }
170}
171
172/// Use Active Inference to predict useful provider data and prefetch it.
173/// Stores results in session cache (synchronous) and triggers deep
174/// indexing (BM25, Graph, Knowledge) in a background thread when
175/// `providers.auto_index` is enabled.
176fn predict_and_prefetch(
177    task: &str,
178    cache: &mut crate::core::cache::SessionCache,
179    project_root: &str,
180) -> String {
181    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(project_root)));
182    let registry = crate::core::providers::registry::global_registry();
183    let available = registry.available_provider_ids();
184    if available.is_empty() {
185        return String::new();
186    }
187
188    let mut bandit = crate::core::provider_bandit::ProviderBandit::load(project_root);
189    let predictions =
190        crate::core::active_inference::predict_preloads(task, &available, &mut bandit, 2);
191
192    if predictions.is_empty() {
193        return String::new();
194    }
195    let task_type = crate::core::active_inference::infer_task_type(&task.to_lowercase());
196
197    let cfg = crate::core::config::Config::load();
198    let auto_index = cfg.providers.auto_index;
199    let mut all_artifacts = Vec::new();
200
201    let mut out = String::from("\n\n--- PROVIDER PRELOAD ---");
202    let mut prefetched = 0usize;
203
204    for pred in &predictions {
205        let params = crate::core::providers::provider_trait::ProviderParams {
206            limit: Some(5),
207            ..Default::default()
208        };
209
210        match registry.execute_as_chunks(&pred.provider_id, &pred.action, &params) {
211            Ok(chunks) => {
212                // Active-inference feedback: a provider that actually returned
213                // context for this task type is a positive prediction error.
214                bandit.update(&task_type, &pred.provider_id, !chunks.is_empty());
215                let artifacts = crate::core::consolidation::consolidate(&chunks);
216                for entry in &artifacts.cache_entries {
217                    cache.store(&entry.uri, &entry.content);
218                    prefetched += 1;
219                }
220                if auto_index && !artifacts.is_empty() {
221                    all_artifacts.push(artifacts);
222                }
223                out.push_str(&format!(
224                    "\n  {} {} → {} items cached (confidence: {:.0}%)",
225                    pred.provider_id,
226                    pred.action,
227                    chunks.len(),
228                    pred.confidence * 100.0,
229                ));
230            }
231            Err(e) => {
232                // A failed/empty provider is a negative prediction error — learn
233                // not to bet on it for this task type next time.
234                bandit.update(&task_type, &pred.provider_id, false);
235                tracing::debug!(
236                    "[preload] provider {}/{} failed: {e}",
237                    pred.provider_id,
238                    pred.action,
239                );
240            }
241        }
242    }
243
244    // Persist the learning even when nothing prefetched — negative outcomes are
245    // exactly what we want the bandit to remember.
246    let _ = bandit.save(project_root);
247
248    if prefetched == 0 {
249        return String::new();
250    }
251
252    if !all_artifacts.is_empty() {
253        let root = project_root.to_string();
254        std::thread::spawn(move || {
255            let merged = merge_preload_artifacts(&all_artifacts);
256            crate::tools::ctx_provider::apply_artifacts_to_stores(&merged, &root);
257        });
258    }
259
260    out
261}
262
263fn merge_preload_artifacts(
264    all: &[crate::core::consolidation::ConsolidationArtifacts],
265) -> crate::core::consolidation::ConsolidationArtifacts {
266    let mut merged = crate::core::consolidation::ConsolidationArtifacts::default();
267    for a in all {
268        merged.bm25_chunks.extend(a.bm25_chunks.clone());
269        merged.edges.extend(a.edges.clone());
270        merged.facts.extend(a.facts.clone());
271        merged.cache_entries.extend(a.cache_entries.clone());
272    }
273    merged
274}