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 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);
75 if !provider_hints.is_empty() {
76 result.push_str(&provider_hints);
77 }
78
79 drop(cache_guard);
80
81 if let Some(ref session_lock) = ctx.session {
82 if let Some(mut session_guard) =
83 crate::server::bounded_lock::write(session_lock, "ctx_preload:session_write")
84 {
85 if session_guard.active_structured_intent.is_none()
86 || session_guard
87 .active_structured_intent
88 .as_ref()
89 .is_none_or(|i| i.confidence < 0.6)
90 {
91 session_guard.set_task(&task, Some("preload"));
92 }
93 }
94
95 if let Some(session_guard) =
96 crate::server::bounded_lock::read(session_lock, "ctx_preload:session_read")
97 {
98 if let Some(ref intent) = session_guard.active_structured_intent {
99 if let Some(ref ledger_lock) = ctx.ledger {
100 let Some(ledger) =
101 crate::server::bounded_lock::read(ledger_lock, "ctx_preload:ledger")
102 else {
103 return Ok(ToolOutput::simple(result));
104 };
105 if !ledger.entries.is_empty() {
106 let known: Vec<String> = session_guard
107 .files_touched
108 .iter()
109 .map(|f| f.path.clone())
110 .collect();
111 let deficit = crate::core::context_deficit::detect_deficit(
112 &ledger, intent, &known,
113 );
114 if !deficit.suggested_files.is_empty() {
115 result.push_str("\n\n--- SUGGESTED FILES ---");
116 for s in &deficit.suggested_files {
117 result.push_str(&format!(
118 "\n {} ({:?}, ~{} tok, mode: {})",
119 s.path, s.reason, s.estimated_tokens, s.recommended_mode
120 ));
121 }
122 }
123
124 let pressure = ledger.pressure();
125 if pressure.utilization > 0.7 {
126 let plan = ledger.reinjection_plan(intent, 0.6);
127 if !plan.actions.is_empty() {
128 result.push_str("\n\n--- REINJECTION PLAN ---");
129 result.push_str(&format!(
130 "\n Context pressure: {:.0}% -> target: 60%",
131 pressure.utilization * 100.0
132 ));
133 for a in &plan.actions {
134 result.push_str(&format!(
135 "\n {} : {} -> {} (frees ~{} tokens)",
136 a.path, a.current_mode, a.new_mode, a.tokens_freed
137 ));
138 }
139 result.push_str(&format!(
140 "\n Total freeable: {} tokens",
141 plan.total_tokens_freed
142 ));
143 }
144 }
145 }
146 }
147 }
148 }
149 }
150
151 Ok(ToolOutput {
152 text: result,
153 original_tokens: 0,
154 saved_tokens: 0,
155 mode: Some("preload".to_string()),
156 path: None,
157 changed: false,
158 })
159 }
160}
161
162fn predict_and_prefetch(task: &str, cache: &mut crate::core::cache::SessionCache) -> String {
166 crate::core::providers::init::init_builtin_providers();
167 let registry = crate::core::providers::registry::global_registry();
168 let available = registry.available_provider_ids();
169 if available.is_empty() {
170 return String::new();
171 }
172
173 let mut bandit = crate::core::provider_bandit::ProviderBandit::new();
174 let predictions =
175 crate::core::active_inference::predict_preloads(task, &available, &mut bandit, 2);
176
177 if predictions.is_empty() {
178 return String::new();
179 }
180
181 let mut out = String::from("\n\n--- PROVIDER PRELOAD ---");
182 let mut prefetched = 0usize;
183
184 for pred in &predictions {
185 let params = crate::core::providers::provider_trait::ProviderParams {
186 limit: Some(5),
187 ..Default::default()
188 };
189
190 match registry.execute_as_chunks(&pred.provider_id, &pred.action, ¶ms) {
191 Ok(chunks) => {
192 let artifacts = crate::core::consolidation::consolidate(&chunks);
193 for entry in &artifacts.cache_entries {
194 cache.store(&entry.uri, &entry.content);
195 prefetched += 1;
196 }
197 out.push_str(&format!(
198 "\n {} {} → {} items cached (confidence: {:.0}%)",
199 pred.provider_id,
200 pred.action,
201 chunks.len(),
202 pred.confidence * 100.0,
203 ));
204 }
205 Err(e) => {
206 tracing::debug!(
207 "[preload] provider {}/{} failed: {e}",
208 pred.provider_id,
209 pred.action,
210 );
211 }
212 }
213 }
214
215 if prefetched == 0 {
216 return String::new();
217 }
218
219 out
220}