lean_ctx/tools/registered/
ctx_read.rs1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_bool, get_int, get_str, McpTool, ToolContext, ToolOutput};
6use crate::tool_defs::tool_def;
7use crate::tools::LeanCtxServer;
8
9pub struct CtxReadTool;
10
11impl McpTool for CtxReadTool {
12 fn name(&self) -> &'static str {
13 "ctx_read"
14 }
15
16 fn tool_def(&self) -> Tool {
17 tool_def(
18 "ctx_read",
19 "Read file (cached, compressed). Cached re-reads can be ~13 tok when unchanged. Auto-selects optimal mode. \
20Modes: full|map|signatures|diff|aggressive|entropy|task|reference|lines:N-M. fresh=true forces a disk re-read.",
21 json!({
22 "type": "object",
23 "properties": {
24 "path": { "type": "string", "description": "Absolute file path to read" },
25 "mode": {
26 "type": "string",
27 "description": "Compression mode (default: full). Use 'map' for context-only files. For line ranges: 'lines:N-M' (e.g. 'lines:400-500')."
28 },
29 "start_line": {
30 "type": "integer",
31 "description": "Read from this line number to end of file. Implies fresh=true (disk re-read) to avoid stale snippets."
32 },
33 "fresh": {
34 "type": "boolean",
35 "description": "Bypass cache and force a full re-read. Use when running as a subagent that may not have the parent's context."
36 }
37 },
38 "required": ["path"]
39 }),
40 )
41 }
42
43 fn handle(
44 &self,
45 args: &Map<String, Value>,
46 ctx: &ToolContext,
47 ) -> Result<ToolOutput, ErrorData> {
48 let path = ctx
49 .resolved_path("path")
50 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?
51 .to_string();
52
53 tokio::task::block_in_place(|| self.handle_inner(args, ctx, &path))
54 }
55}
56
57impl CtxReadTool {
58 #[allow(clippy::unused_self)]
59 fn handle_inner(
60 &self,
61 args: &Map<String, Value>,
62 ctx: &ToolContext,
63 path: &str,
64 ) -> Result<ToolOutput, ErrorData> {
65 let session_lock = ctx
66 .session
67 .as_ref()
68 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
69 let cache_lock = ctx
70 .cache
71 .as_ref()
72 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
73
74 let current_task = {
75 let session = session_lock.blocking_read();
76 session.task.as_ref().map(|t| t.description.clone())
77 };
78 let task_ref = current_task.as_deref();
79
80 let profile = crate::core::profiles::active_profile();
81 let mut mode = if let Some(m) = get_str(args, "mode") {
82 m
83 } else if profile.read.default_mode_effective() == "auto" {
84 let cache = cache_lock.blocking_read();
85 crate::tools::ctx_smart_read::select_mode_with_task(&cache, path, task_ref)
86 } else {
87 profile.read.default_mode_effective().to_string()
88 };
89
90 let mut fresh = get_bool(args, "fresh").unwrap_or(false);
91 let start_line = get_int(args, "start_line");
92 if let Some(sl) = start_line {
93 let sl = sl.max(1_i64);
94 mode = format!("lines:{sl}-999999");
95 fresh = true;
96 }
97
98 let mode = if crate::tools::ctx_read::is_instruction_file(path) {
99 "full".to_string()
100 } else {
101 auto_degrade_read_mode(&mode)
102 };
103
104 let effective_mode = LeanCtxServer::upgrade_mode_if_stale(&mode, false).to_string();
105
106 if mode.starts_with("lines:") {
107 fresh = true;
108 }
109
110 if crate::core::binary_detect::is_binary_file(path) {
111 let msg = crate::core::binary_detect::binary_file_message(path);
112 return Err(ErrorData::invalid_params(msg, None));
113 }
114 {
115 let cap = crate::core::limits::max_read_bytes() as u64;
116 if let Ok(meta) = std::fs::metadata(path) {
117 if meta.len() > cap {
118 let msg = format!(
119 "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
120 Use mode=\"lines:1-100\" for partial reads or increase the limit.",
121 meta.len(),
122 cap
123 );
124 return Err(ErrorData::invalid_params(msg, None));
125 }
126 }
127 }
128
129 let mut cache = cache_lock.blocking_write();
130 let read_output = if fresh {
131 crate::tools::ctx_read::handle_fresh_with_task_resolved(
132 &mut cache,
133 path,
134 &effective_mode,
135 ctx.crp_mode,
136 task_ref,
137 )
138 } else {
139 crate::tools::ctx_read::handle_with_task_resolved(
140 &mut cache,
141 path,
142 &effective_mode,
143 ctx.crp_mode,
144 task_ref,
145 )
146 };
147 let output = read_output.content;
148 let resolved_mode = read_output.resolved_mode;
149
150 let stale_note = if !ctx.minimal && effective_mode != mode {
151 format!("[cache stale, {mode}→{effective_mode}]\n")
152 } else {
153 String::new()
154 };
155 let original = cache.get(path).map_or(0, |e| e.original_tokens);
156 let is_cache_hit = output.contains(" cached ");
157 let output = format!("{stale_note}{output}");
158 let output_tokens = crate::core::tokens::count_tokens(&output);
159 let saved = original.saturating_sub(output_tokens);
160 let file_ref = cache.file_ref_map().get(path).cloned();
161 drop(cache);
162
163 let mut ensured_root: Option<String> = None;
165 {
166 let mut session = session_lock.blocking_write();
167 session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
168 if is_cache_hit {
169 session.record_cache_hit();
170 }
171 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
172 let touched: Vec<String> = session
173 .files_touched
174 .iter()
175 .map(|f| f.path.clone())
176 .collect();
177 let inferred =
178 crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
179 if inferred.confidence >= 0.4 {
180 session.active_structured_intent = Some(inferred);
181 }
182 }
183 let root_missing = session
184 .project_root
185 .as_deref()
186 .is_none_or(|r| r.trim().is_empty());
187 if root_missing {
188 if let Some(root) = crate::core::protocol::detect_project_root(path) {
189 session.project_root = Some(root.clone());
190 ensured_root = Some(root);
191 }
192 }
193 }
194
195 if let Some(root) = ensured_root.as_deref() {
196 crate::core::index_orchestrator::ensure_all_background(root);
197 }
198
199 crate::core::heatmap::record_file_access(path, original, saved);
200
201 {
203 let sig = crate::core::mode_predictor::FileSignature::from_path(path, original);
204 let density = if output_tokens > 0 {
205 original as f64 / output_tokens as f64
206 } else {
207 1.0
208 };
209 let outcome = crate::core::mode_predictor::ModeOutcome {
210 mode: resolved_mode.clone(),
211 tokens_in: original,
212 tokens_out: output_tokens,
213 density: density.min(1.0),
214 };
215 let project_root = {
216 let session = session_lock.blocking_read();
217 session
218 .project_root
219 .clone()
220 .unwrap_or_else(|| ".".to_string())
221 };
222 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
223 predictor.set_project_root(&project_root);
224 predictor.record(sig, outcome);
225 predictor.save();
226
227 let ext = std::path::Path::new(path)
228 .extension()
229 .and_then(|e| e.to_str())
230 .unwrap_or("")
231 .to_string();
232 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(path);
233 let cache = cache_lock.blocking_read();
234 let stats = cache.get_stats();
235 let feedback_outcome = crate::core::feedback::CompressionOutcome {
236 session_id: format!("{}", std::process::id()),
237 language: ext,
238 entropy_threshold: thresholds.bpe_entropy,
239 jaccard_threshold: thresholds.jaccard,
240 total_turns: stats.total_reads as u32,
241 tokens_saved: saved as u64,
242 tokens_original: original as u64,
243 cache_hits: stats.cache_hits as u32,
244 total_reads: stats.total_reads as u32,
245 task_completed: true,
246 timestamp: chrono::Local::now().to_rfc3339(),
247 };
248 drop(cache);
249 let mut store = crate::core::feedback::FeedbackStore::load();
250 store.project_root = Some(project_root);
251 store.record_outcome(feedback_outcome);
252 }
253
254 Ok(ToolOutput {
260 text: output,
261 original_tokens: original,
262 saved_tokens: saved,
263 mode: Some(resolved_mode),
264 path: Some(path.to_string()),
265 })
266 }
267}
268
269fn auto_degrade_read_mode(mode: &str) -> String {
270 use crate::core::degradation_policy::DegradationVerdictV1;
271 let profile = crate::core::profiles::active_profile();
272 if !profile.degradation.enforce_effective() {
273 return mode.to_string();
274 }
275 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
276 match policy.decision.verdict {
277 DegradationVerdictV1::Ok => mode.to_string(),
278 DegradationVerdictV1::Warn => match mode {
279 "full" => "map".to_string(),
280 other => other.to_string(),
281 },
282 DegradationVerdictV1::Throttle => match mode {
283 "full" | "map" => "signatures".to_string(),
284 other => other.to_string(),
285 },
286 DegradationVerdictV1::Block => "signatures".to_string(),
287 }
288}