Skip to main content

winx_code_agent/tools/
read_files.rs

1//! Implementation of the `ReadFiles` tool.
2//!
3//! This module provides the implementation for the `ReadFiles` tool, which is used
4//! to read and display the contents of files, optionally with line numbers and
5//! line range filtering.
6
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fmt::Write as FmtWrite;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use tokio::sync::Mutex;
13use tracing::{debug, instrument};
14
15use crate::errors::{ErrorRecovery, Result, WinxError};
16use crate::state::bash_state::BashState;
17use crate::types::ReadFiles;
18use crate::utils::mmap::read_file_to_string;
19use crate::utils::path::{expand_user, validate_path_in_workspace};
20
21/// Default token limits for file reading
22const CODING_MAX_TOKENS: usize = 24_000;
23const NONCODING_MAX_TOKENS: usize = 8_000;
24const DEFAULT_READ_PARALLELISM: usize = 4;
25const MAX_READ_PARALLELISM: usize = 32;
26
27/// Type alias for file reading result
28type FileReadResult = (String, bool, usize, String, (usize, usize), String, usize);
29type ReadCoverage = (Vec<(usize, usize)>, String, usize);
30
31#[derive(Clone)]
32struct FileReadRequest {
33    index: usize,
34    requested_path: String,
35    clean_path: String,
36    start_line_num: Option<usize>,
37    end_line_num: Option<usize>,
38}
39
40#[derive(Default)]
41struct ReadBatchState {
42    message: String,
43    file_ranges: HashMap<String, ReadCoverage>,
44    stats_paths: Vec<PathBuf>,
45    successful_files: usize,
46    errors: Vec<WinxError>,
47}
48
49impl ReadBatchState {
50    /// Add one worker result to the ordered response. Returns `true` when the
51    /// visible batch must stop because this file exhausted its token budget.
52    fn record_result(
53        &mut self,
54        request: &FileReadRequest,
55        result: Result<FileReadResult>,
56        total_files: usize,
57    ) -> bool {
58        match result {
59            Ok((content, truncated, _, canon_path, line_range, file_hash, total_lines)) => {
60                self.successful_files = self.successful_files.saturating_add(1);
61                let entry = self
62                    .file_ranges
63                    .entry(canon_path.clone())
64                    .or_insert_with(|| (Vec::new(), file_hash.clone(), total_lines));
65                if entry.1 != file_hash || entry.2 != total_lines {
66                    // The same path can be requested more than once in one
67                    // batch. If it changed between reads, keep coverage only
68                    // for the version whose hash will guard the next edit.
69                    *entry = (Vec::new(), file_hash, total_lines);
70                }
71                entry.0.push(line_range);
72                let _ = write!(
73                    self.message,
74                    "\n{}{}\n```\n{content}\n```",
75                    request.clean_path,
76                    range_format(request.start_line_num, request.end_line_num)
77                );
78                self.stats_paths.push(PathBuf::from(canon_path));
79
80                if truncated {
81                    let remaining = total_files.saturating_sub(request.index + 1);
82                    if remaining > 0 {
83                        let _ = write!(
84                            self.message,
85                            "\n\n(Not reading the remaining {remaining} file(s) due to the \
86                             token limit. Call ReadFiles again for them.)"
87                        );
88                    }
89                }
90                truncated
91            }
92            Err(error) => {
93                let _ = write!(self.message, "\nError reading {}: {error}", request.requested_path);
94                self.errors.push(error);
95                false
96            }
97        }
98    }
99}
100
101/// Complete result of one batched read. The MCP adapter uses the per-file
102/// errors to return an honest `isError: true` result while retaining any
103/// successfully read content in the same response.
104#[derive(Debug)]
105pub struct ReadFilesOutcome {
106    pub text: String,
107    pub successful_files: usize,
108    pub errors: Vec<WinxError>,
109}
110
111/// Maximum amount of data to read from a file
112const MAX_FILE_SIZE: u64 = 50_000_000;
113
114fn range_format(start_line_num: Option<usize>, end_line_num: Option<usize>) -> String {
115    let st = start_line_num.map_or(String::new(), |n| n.to_string());
116    let end = end_line_num.map_or(String::new(), |n| n.to_string());
117
118    if st.is_empty() && end.is_empty() {
119        String::new()
120    } else {
121        format!(":{st}-{end}")
122    }
123}
124
125#[instrument(level = "debug", skip(file_path))]
126fn read_file(
127    file_path: &str,
128    max_tokens: Option<usize>,
129    cwd: &Path,
130    workspace_root: &Path,
131    show_line_numbers: bool,
132    start_line_num: Option<usize>,
133    end_line_num: Option<usize>,
134) -> Result<FileReadResult> {
135    let file_path_expanded = expand_user(file_path);
136    let path = if Path::new(&file_path_expanded).is_absolute() {
137        PathBuf::from(&file_path_expanded)
138    } else {
139        cwd.join(&file_path_expanded)
140    };
141
142    if !path.exists() {
143        return Err(WinxError::FileAccessError {
144            path: path.clone(),
145            message: "File does not exist".to_string(),
146        });
147    }
148
149    let path = match validate_path_in_workspace(&path, workspace_root) {
150        Ok(canonical) => canonical,
151        Err(security_err) => {
152            return Err(WinxError::PathSecurityError {
153                path: path.clone(),
154                message: security_err.to_string(),
155            });
156        }
157    };
158
159    if !path.is_file() {
160        return Err(WinxError::FileAccessError {
161            path: path.clone(),
162            message: "Path exists but is not a file".to_string(),
163        });
164    }
165
166    let content = read_file_to_string(&path, MAX_FILE_SIZE)?;
167    let file_hash = hash_content(&content);
168    let lines: Vec<&str> = content.lines().collect();
169    let total_lines = lines.len();
170
171    let start_idx = start_line_num.map_or(0, |n| n.saturating_sub(1).min(lines.len()));
172    let end_idx = end_line_num.map_or(lines.len(), |n| n.min(lines.len()));
173
174    if start_idx > lines.len() || start_idx > end_idx {
175        return Err(ErrorRecovery::param_error(
176            "line_range",
177            &format!("Invalid line range for file with {} lines", lines.len()),
178        ));
179    }
180
181    let effective_start = start_line_num.unwrap_or(1);
182    let effective_end = end_line_num.unwrap_or(total_lines);
183
184    let filtered_lines =
185        if lines.is_empty() { &[] } else { &lines[start_idx..end_idx.min(lines.len())] };
186    let mut result_content = String::new();
187
188    if show_line_numbers {
189        for (i, line) in filtered_lines.iter().enumerate() {
190            let line_num = start_idx + i + 1;
191            let _ = writeln!(result_content, "{line_num} {line}");
192        }
193    } else {
194        for line in filtered_lines {
195            result_content.push_str(line);
196            result_content.push('\n');
197        }
198    }
199
200    let mut truncated = false;
201    let mut last_shown = effective_end;
202    let max_tokens = max_tokens.unwrap_or_else(|| select_max_tokens(file_path));
203    // Byte-level BPE emits at most one token per input byte. Small payloads are
204    // therefore proven to fit without initializing the tokenizer; larger ones
205    // still get exact counting and token-boundary truncation.
206    let (tokens_count, token_ids) =
207        if crate::utils::encoder::definitely_fits_token_budget(&result_content, max_tokens) {
208            (crate::utils::encoder::estimate_tokens(&result_content), None)
209        } else {
210            let ids = crate::utils::encoder::encode_ids(&result_content);
211            let count = ids
212                .as_ref()
213                .map_or_else(|| crate::utils::encoder::estimate_tokens(&result_content), Vec::len);
214            (count, ids)
215        };
216
217    if tokens_count > max_tokens {
218        truncate_to_token_budget(&mut result_content, max_tokens, token_ids);
219        // Tell the agent exactly where to resume so the tail isn't silently lost.
220        let kept_lines = result_content.lines().count();
221        last_shown = (start_idx + kept_lines).min(total_lines);
222        let resume_from = last_shown + 1;
223        let _ = write!(
224            result_content,
225            "\n(...truncated) Showing up to line {last_shown} of {total_lines} total lines \
226             ({tokens_count} tokens exceeded limit {max_tokens}). Continue reading from line \
227             {resume_from} using the syntax {file_path}:{resume_from}-{total_lines}"
228        );
229        truncated = true;
230    }
231
232    let canon_path = path.to_string_lossy().to_string();
233    let effective_end_line = if truncated { last_shown } else { effective_end };
234
235    Ok((
236        result_content,
237        truncated,
238        tokens_count,
239        canon_path,
240        (effective_start, effective_end_line.min(total_lines.max(1))),
241        file_hash,
242        total_lines,
243    ))
244}
245
246fn hash_content(content: &str) -> String {
247    let digest = Sha256::digest(content.as_bytes());
248    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
249        let _ = write!(hash, "{byte:02x}");
250        hash
251    })
252}
253
254fn truncate_to_token_budget(content: &mut String, max_tokens: usize, ids: Option<Vec<u32>>) {
255    // `ids` were already computed by the caller (the token count needs them too),
256    // so reuse them instead of re-encoding the whole string a second time here.
257    let Some(ids) = ids else {
258        // No tokenizer available: fall back to a char-count cut.
259        let byte_idx = byte_index_for_char_count(content, max_tokens);
260        content.truncate(byte_idx);
261        trim_to_last_line_boundary(content);
262        return;
263    };
264
265    if ids.len() <= max_tokens {
266        return;
267    }
268
269    if let Some(decoded) = crate::utils::encoder::decode_ids(&ids[..max_tokens]) {
270        *content = decoded;
271    } else {
272        let byte_idx = byte_index_for_char_count(content, max_tokens);
273        content.truncate(byte_idx);
274    }
275    trim_to_last_line_boundary(content);
276}
277
278fn trim_to_last_line_boundary(content: &mut String) {
279    if let Some(last_nl) = content.rfind('\n') {
280        content.truncate(last_nl + 1);
281    } else {
282        // A partial first line is not safe to record as read: the continuation
283        // would skip its unseen suffix. Keep no content/range and resume from
284        // the same line instead.
285        content.clear();
286    }
287}
288
289fn byte_index_for_char_count(content: &str, char_count: usize) -> usize {
290    content.char_indices().nth(char_count).map_or(content.len(), |(idx, _)| idx)
291}
292
293fn select_max_tokens(file_path: &str) -> usize {
294    // Budgets are overridable per deployment via env vars so large-context
295    // clients can pull more of each file into context (defaults match wcgw).
296    if is_source_code_file(file_path) {
297        crate::utils::encoder::budget_from_env("WINX_CODING_TOKEN_BUDGET", CODING_MAX_TOKENS)
298    } else {
299        crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", NONCODING_MAX_TOKENS)
300    }
301}
302
303pub(crate) fn configured_parallelism() -> usize {
304    parse_read_parallelism(crate::config::env_text("WINX_READ_PARALLELISM").as_deref())
305}
306
307fn parse_read_parallelism(value: Option<&str>) -> usize {
308    value
309        .and_then(|value| value.parse::<usize>().ok())
310        .filter(|parallelism| *parallelism > 0)
311        .unwrap_or(DEFAULT_READ_PARALLELISM)
312        .min(MAX_READ_PARALLELISM)
313}
314
315fn is_source_code_file(file_path: &str) -> bool {
316    let path = Path::new(file_path);
317    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
318    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
319
320    matches!(file_name, "Makefile" | "Dockerfile" | "Jenkinsfile")
321        || matches!(
322            extension,
323            "py" | "pyx"
324                | "pyi"
325                | "pyw"
326                | "js"
327                | "jsx"
328                | "ts"
329                | "tsx"
330                | "mjs"
331                | "cjs"
332                | "html"
333                | "css"
334                | "scss"
335                | "sass"
336                | "less"
337                | "c"
338                | "h"
339                | "cpp"
340                | "cxx"
341                | "cc"
342                | "hpp"
343                | "java"
344                | "kt"
345                | "go"
346                | "rs"
347                | "rb"
348                | "php"
349                | "sh"
350                | "bash"
351                | "zsh"
352                | "sql"
353                | "xml"
354                | "json"
355                | "yaml"
356                | "yml"
357                | "toml"
358                | "md"
359                | "ex"
360                | "exs"
361        )
362}
363
364async fn execute_read_requests(
365    requests: &[FileReadRequest],
366    cwd: &Path,
367    workspace_root: &Path,
368    show_line_numbers: bool,
369) -> ReadBatchState {
370    let mut state = ReadBatchState::default();
371
372    // Files are independent, so perform blocking filesystem/tokenizer work in a
373    // bounded pool. Results are still consumed in request order, preserving the
374    // stable response and the rule that a truncated file stops the visible batch.
375    'batches: for batch in requests.chunks(configured_parallelism()) {
376        let mut tasks = Vec::with_capacity(batch.len());
377        for request in batch.iter().cloned() {
378            let worker_path = request.clean_path.clone();
379            let worker_cwd = cwd.to_path_buf();
380            let worker_root = workspace_root.to_path_buf();
381            let start_line_num = request.start_line_num;
382            let end_line_num = request.end_line_num;
383            let task = tokio::task::spawn_blocking(move || {
384                read_file(
385                    &worker_path,
386                    Some(select_max_tokens(&worker_path)),
387                    &worker_cwd,
388                    &worker_root,
389                    show_line_numbers,
390                    start_line_num,
391                    end_line_num,
392                )
393            });
394            tasks.push((request, task));
395        }
396
397        let mut tasks = tasks.into_iter();
398        while let Some((request, task)) = tasks.next() {
399            let result = task.await.unwrap_or_else(|error| {
400                Err(WinxError::CommandExecutionError(format!(
401                    "ReadFiles worker failed for {}: {error}",
402                    request.clean_path
403                )))
404            });
405            if state.record_result(&request, result, requests.len()) {
406                // Running blocking jobs cannot be cancelled, but abort prevents
407                // queued work from starting. Only returned content is guarded.
408                for (_, pending) in tasks {
409                    pending.abort();
410                }
411                break 'batches;
412            }
413        }
414    }
415    state
416}
417
418async fn persist_read_stats(workspace_root: &Path, stats_paths: Vec<PathBuf>) {
419    if stats_paths.is_empty() {
420        return;
421    }
422    let stats_root = workspace_root.to_path_buf();
423    let result = tokio::task::spawn_blocking(move || {
424        crate::utils::workspace_stats::record_reads(&stats_root, &stats_paths)
425    })
426    .await;
427    match result {
428        Ok(Err(error)) => debug!("failed to record read stats: {error}"),
429        Err(error) => debug!("read stats worker failed: {error}"),
430        Ok(Ok(())) => {}
431    }
432}
433
434pub async fn handle_tool_call(
435    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
436    read_files: ReadFiles,
437) -> Result<String> {
438    let outcome = handle_tool_call_detailed(bash_state_arc, read_files).await?;
439    if outcome.successful_files == 0 {
440        if let Some(error) = outcome.errors.into_iter().next() {
441            return Err(error);
442        }
443    }
444    Ok(outcome.text)
445}
446
447pub async fn handle_tool_call_detailed(
448    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
449    read_files: ReadFiles,
450) -> Result<ReadFilesOutcome> {
451    let (cwd, workspace_root) = {
452        let bash_state_guard = bash_state_arc.lock().await;
453        let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
454        (bash_state.cwd.clone(), bash_state.workspace_root.clone())
455    };
456
457    let requests = read_files
458        .file_paths
459        .iter()
460        .enumerate()
461        .map(|(index, file_path)| FileReadRequest {
462            index,
463            requested_path: file_path.clone(),
464            clean_path: read_files.get_clean_path(index),
465            start_line_num: read_files.start_line_nums.get(index).copied().flatten(),
466            end_line_num: read_files.end_line_nums.get(index).copied().flatten(),
467        })
468        .collect::<Vec<_>>();
469    let batch =
470        execute_read_requests(&requests, &cwd, &workspace_root, read_files.show_line_numbers())
471            .await;
472    let ReadBatchState { message, file_ranges, stats_paths, successful_files, errors } = batch;
473    persist_read_stats(&workspace_root, stats_paths).await;
474
475    let mut bash_state_guard = bash_state_arc.lock().await;
476    if let Some(bash_state) = bash_state_guard.as_mut() {
477        for (path, (ranges, file_hash, total_lines)) in file_ranges {
478            bash_state.record_read_coverage(&path, ranges, file_hash, total_lines);
479        }
480    }
481
482    Ok(ReadFilesOutcome { text: message, successful_files, errors })
483}
484
485#[cfg(test)]
486mod tests {
487    use std::fmt::Write as _;
488
489    use super::{parse_read_parallelism, read_file, trim_to_last_line_boundary};
490    use crate::state::bash_state::FileWhitelistData;
491
492    #[test]
493    fn token_cut_never_exposes_a_partial_line() {
494        let mut content = "complete line\npartial line suffix".to_string();
495        content.truncate("complete line\npartial".len());
496        trim_to_last_line_boundary(&mut content);
497        assert_eq!(content, "complete line\n");
498
499        let mut first_line_only = "partial first line".to_string();
500        first_line_only.truncate(7);
501        trim_to_last_line_boundary(&mut first_line_only);
502        assert!(first_line_only.is_empty());
503    }
504
505    #[test]
506    fn token_truncation_whitelists_only_complete_visible_lines() -> anyhow::Result<()> {
507        let temp = tempfile::tempdir()?;
508        let path = temp.path().join("large.txt");
509        let mut source = String::new();
510        for line in 1..=200 {
511            writeln!(source, "line {line}: enough repeated content to consume tokens quickly")?;
512        }
513        std::fs::write(&path, source)?;
514        let path = path.to_str().ok_or_else(|| anyhow::anyhow!("fixture path is not UTF-8"))?;
515
516        let (content, truncated, _, _, range, hash, total_lines) =
517            read_file(path, Some(50), temp.path(), temp.path(), false, None, None)?;
518
519        assert!(truncated);
520        let visible = content
521            .split_once("\n(...truncated)")
522            .map(|(visible, _)| visible)
523            .ok_or_else(|| anyhow::anyhow!("truncation marker is missing"))?;
524        let visible_lines = visible.lines().count();
525        assert_eq!(range, (1, visible_lines));
526        let coverage = FileWhitelistData::new(hash, vec![range], total_lines);
527        assert!(!coverage.is_read_enough());
528        assert_eq!(coverage.get_unread_ranges(), vec![(visible_lines + 1, total_lines)]);
529        Ok(())
530    }
531
532    #[test]
533    fn oversized_first_line_records_no_read_coverage() -> anyhow::Result<()> {
534        let temp = tempfile::tempdir()?;
535        let path = temp.path().join("single-line.txt");
536        std::fs::write(&path, "x".repeat(10_000))?;
537        let path = path.to_str().ok_or_else(|| anyhow::anyhow!("fixture path is not UTF-8"))?;
538
539        let (content, truncated, _, _, range, hash, total_lines) =
540            read_file(path, Some(10), temp.path(), temp.path(), false, None, None)?;
541
542        assert!(truncated);
543        assert!(content.starts_with("\n(...truncated) Showing up to line 0"));
544        assert_eq!(range, (1, 0));
545        let coverage = FileWhitelistData::new(hash, vec![range], total_lines);
546        assert!(coverage.line_ranges_read.is_empty());
547        assert_eq!(coverage.get_unread_ranges(), vec![(1, 1)]);
548        Ok(())
549    }
550
551    #[test]
552    fn parallelism_uses_safe_defaults_and_bounds() {
553        assert_eq!(parse_read_parallelism(None), 4);
554        assert_eq!(parse_read_parallelism(Some("invalid")), 4);
555        assert_eq!(parse_read_parallelism(Some("0")), 4);
556        assert_eq!(parse_read_parallelism(Some("1")), 1);
557        assert_eq!(parse_read_parallelism(Some("8")), 8);
558        assert_eq!(parse_read_parallelism(Some("999")), 32);
559    }
560}