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