Skip to main content

lean_ctx/tools/registered/
ctx_preload.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_str, McpTool, ToolContext, ToolOutput};
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 (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        let cache = ctx.cache.as_ref().unwrap();
59        let Some(mut cache_guard) = crate::server::bounded_lock::write(cache, "ctx_preload:cache")
60        else {
61            return Ok(ToolOutput::simple(
62                "[preload skipped — cache temporarily unavailable]".to_string(),
63            ));
64        };
65        let mut result = crate::tools::ctx_preload::handle(
66            &mut cache_guard,
67            &task,
68            resolved_path.as_deref(),
69            ctx.crp_mode,
70        );
71
72        let provider_hints = predict_and_prefetch(&task, &mut cache_guard, &ctx.project_root);
73        if !provider_hints.is_empty() {
74            result.push_str(&provider_hints);
75        }
76
77        drop(cache_guard);
78
79        if let Some(ref session_lock) = ctx.session {
80            if let Some(mut session_guard) =
81                crate::server::bounded_lock::write(session_lock, "ctx_preload:session_write")
82            {
83                if session_guard.active_structured_intent.is_none()
84                    || session_guard
85                        .active_structured_intent
86                        .as_ref()
87                        .is_none_or(|i| i.confidence < 0.6)
88                {
89                    session_guard.set_task(&task, Some("preload"));
90                }
91            }
92
93            if let Some(session_guard) =
94                crate::server::bounded_lock::read(session_lock, "ctx_preload:session_read")
95            {
96                if let Some(ref intent) = session_guard.active_structured_intent {
97                    if let Some(ref ledger_lock) = ctx.ledger {
98                        let Some(ledger) =
99                            crate::server::bounded_lock::read(ledger_lock, "ctx_preload:ledger")
100                        else {
101                            return Ok(ToolOutput::simple(result));
102                        };
103                        if !ledger.entries.is_empty() {
104                            let known: Vec<String> = session_guard
105                                .files_touched
106                                .iter()
107                                .map(|f| f.path.clone())
108                                .collect();
109                            let deficit = crate::core::context_deficit::detect_deficit(
110                                &ledger, intent, &known,
111                            );
112                            if !deficit.suggested_files.is_empty() {
113                                result.push_str("\n\n--- SUGGESTED FILES ---");
114                                for s in &deficit.suggested_files {
115                                    result.push_str(&format!(
116                                        "\n  {} ({:?}, ~{} tok, mode: {})",
117                                        s.path, s.reason, s.estimated_tokens, s.recommended_mode
118                                    ));
119                                }
120                            }
121
122                            let pressure = ledger.pressure();
123                            if pressure.utilization > 0.7 {
124                                let plan = ledger.reinjection_plan(intent, 0.6);
125                                if !plan.actions.is_empty() {
126                                    result.push_str("\n\n--- REINJECTION PLAN ---");
127                                    result.push_str(&format!(
128                                        "\n  Context pressure: {:.0}% -> target: 60%",
129                                        pressure.utilization * 100.0
130                                    ));
131                                    for a in &plan.actions {
132                                        result.push_str(&format!(
133                                            "\n  {} : {} -> {} (frees ~{} tokens)",
134                                            a.path, a.current_mode, a.new_mode, a.tokens_freed
135                                        ));
136                                    }
137                                    result.push_str(&format!(
138                                        "\n  Total freeable: {} tokens",
139                                        plan.total_tokens_freed
140                                    ));
141                                }
142                            }
143                        }
144                    }
145                }
146            }
147        }
148
149        Ok(ToolOutput {
150            text: result,
151            original_tokens: 0,
152            saved_tokens: 0,
153            mode: Some("preload".to_string()),
154            path: None,
155            changed: false,
156        })
157    }
158}
159
160/// Use Active Inference to predict useful provider data and prefetch it.
161/// Stores results in session cache (synchronous) and triggers deep
162/// indexing (BM25, Graph, Knowledge) in a background thread when
163/// `providers.auto_index` is enabled.
164fn predict_and_prefetch(
165    task: &str,
166    cache: &mut crate::core::cache::SessionCache,
167    project_root: &str,
168) -> String {
169    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(project_root)));
170    let registry = crate::core::providers::registry::global_registry();
171    let available = registry.available_provider_ids();
172    if available.is_empty() {
173        return String::new();
174    }
175
176    let mut bandit = crate::core::provider_bandit::ProviderBandit::new();
177    let predictions =
178        crate::core::active_inference::predict_preloads(task, &available, &mut bandit, 2);
179
180    if predictions.is_empty() {
181        return String::new();
182    }
183
184    let cfg = crate::core::config::Config::load();
185    let auto_index = cfg.providers.auto_index;
186    let mut all_artifacts = Vec::new();
187
188    let mut out = String::from("\n\n--- PROVIDER PRELOAD ---");
189    let mut prefetched = 0usize;
190
191    for pred in &predictions {
192        let params = crate::core::providers::provider_trait::ProviderParams {
193            limit: Some(5),
194            ..Default::default()
195        };
196
197        match registry.execute_as_chunks(&pred.provider_id, &pred.action, &params) {
198            Ok(chunks) => {
199                let artifacts = crate::core::consolidation::consolidate(&chunks);
200                for entry in &artifacts.cache_entries {
201                    cache.store(&entry.uri, &entry.content);
202                    prefetched += 1;
203                }
204                if auto_index && !artifacts.is_empty() {
205                    all_artifacts.push(artifacts);
206                }
207                out.push_str(&format!(
208                    "\n  {} {} → {} items cached (confidence: {:.0}%)",
209                    pred.provider_id,
210                    pred.action,
211                    chunks.len(),
212                    pred.confidence * 100.0,
213                ));
214            }
215            Err(e) => {
216                tracing::debug!(
217                    "[preload] provider {}/{} failed: {e}",
218                    pred.provider_id,
219                    pred.action,
220                );
221            }
222        }
223    }
224
225    if prefetched == 0 {
226        return String::new();
227    }
228
229    if !all_artifacts.is_empty() {
230        let root = project_root.to_string();
231        std::thread::spawn(move || {
232            let merged = merge_preload_artifacts(&all_artifacts);
233            crate::tools::ctx_provider::apply_artifacts_to_stores(&merged, &root);
234        });
235    }
236
237    out
238}
239
240fn merge_preload_artifacts(
241    all: &[crate::core::consolidation::ConsolidationArtifacts],
242) -> crate::core::consolidation::ConsolidationArtifacts {
243    let mut merged = crate::core::consolidation::ConsolidationArtifacts::default();
244    for a in all {
245        merged.bm25_chunks.extend(a.bm25_chunks.clone());
246        merged.edges.extend(a.edges.clone());
247        merged.facts.extend(a.facts.clone());
248        merged.cache_entries.extend(a.cache_entries.clone());
249    }
250    merged
251}