lean_ctx/tools/registered/
ctx_preload.rs1use 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 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 = 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 })
168 }
169}
170
171fn predict_and_prefetch(
176 task: &str,
177 cache: &mut crate::core::cache::SessionCache,
178 project_root: &str,
179) -> String {
180 crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(project_root)));
181 let registry = crate::core::providers::registry::global_registry();
182 let available = registry.available_provider_ids();
183 if available.is_empty() {
184 return String::new();
185 }
186
187 let mut bandit = crate::core::provider_bandit::ProviderBandit::load(project_root);
188 let predictions =
189 crate::core::active_inference::predict_preloads(task, &available, &mut bandit, 2);
190
191 if predictions.is_empty() {
192 return String::new();
193 }
194 let task_type = crate::core::active_inference::infer_task_type(&task.to_lowercase());
195
196 let cfg = crate::core::config::Config::load();
197 let auto_index = cfg.providers.auto_index;
198 let mut all_artifacts = Vec::new();
199
200 let mut out = String::from("\n\n--- PROVIDER PRELOAD ---");
201 let mut prefetched = 0usize;
202
203 for pred in &predictions {
204 let params = crate::core::providers::provider_trait::ProviderParams {
205 limit: Some(5),
206 ..Default::default()
207 };
208
209 match registry.execute_as_chunks(&pred.provider_id, &pred.action, ¶ms) {
210 Ok(chunks) => {
211 bandit.update(&task_type, &pred.provider_id, !chunks.is_empty());
214 let artifacts = crate::core::consolidation::consolidate(&chunks);
215 for entry in &artifacts.cache_entries {
216 cache.store(&entry.uri, &entry.content);
217 prefetched += 1;
218 }
219 if auto_index && !artifacts.is_empty() {
220 all_artifacts.push(artifacts);
221 }
222 out.push_str(&format!(
223 "\n {} {} → {} items cached (confidence: {:.0}%)",
224 pred.provider_id,
225 pred.action,
226 chunks.len(),
227 pred.confidence * 100.0,
228 ));
229 }
230 Err(e) => {
231 bandit.update(&task_type, &pred.provider_id, false);
234 tracing::debug!(
235 "[preload] provider {}/{} failed: {e}",
236 pred.provider_id,
237 pred.action,
238 );
239 }
240 }
241 }
242
243 let _ = bandit.save(project_root);
246
247 if prefetched == 0 {
248 return String::new();
249 }
250
251 if !all_artifacts.is_empty() {
252 let root = project_root.to_string();
253 std::thread::spawn(move || {
254 let merged = merge_preload_artifacts(&all_artifacts);
255 crate::tools::ctx_provider::apply_artifacts_to_stores(&merged, &root);
256 });
257 }
258
259 out
260}
261
262fn merge_preload_artifacts(
263 all: &[crate::core::consolidation::ConsolidationArtifacts],
264) -> crate::core::consolidation::ConsolidationArtifacts {
265 let mut merged = crate::core::consolidation::ConsolidationArtifacts::default();
266 for a in all {
267 merged.bm25_chunks.extend(a.bm25_chunks.clone());
268 merged.edges.extend(a.edges.clone());
269 merged.facts.extend(a.facts.clone());
270 merged.cache_entries.extend(a.cache_entries.clone());
271 }
272 merged
273}