lean_ctx/tools/registered/
ctx_preload.rs1use 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 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 {
95 if session_guard.active_structured_intent.is_none()
96 || session_guard
97 .active_structured_intent
98 .as_ref()
99 .is_none_or(|i| i.confidence < 0.6)
100 {
101 session_guard.set_task(&task, Some("preload"));
102 }
103 }
104
105 if let Some(session_guard) =
106 crate::server::bounded_lock::read(session_lock, "ctx_preload:session_read")
107 {
108 if let Some(ref intent) = session_guard.active_structured_intent {
109 if let Some(ref ledger_lock) = ctx.ledger {
110 let Some(ledger) =
111 crate::server::bounded_lock::read(ledger_lock, "ctx_preload:ledger")
112 else {
113 return Ok(ToolOutput::simple(result));
114 };
115 if !ledger.entries.is_empty() {
116 let known: Vec<String> = session_guard
117 .files_touched
118 .iter()
119 .map(|f| f.path.clone())
120 .collect();
121 let deficit = crate::core::context_deficit::detect_deficit(
122 &ledger, intent, &known,
123 );
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 }
160
161 Ok(ToolOutput {
162 text: result,
163 original_tokens: 0,
164 saved_tokens: 0,
165 mode: Some("preload".to_string()),
166 path: None,
167 changed: false,
168 })
169 }
170}
171
172fn 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, ¶ms) {
211 Ok(chunks) => {
212 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 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 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}