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