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 keeps a batch with
158/// partial successes a SUCCESS when every per-file error is just a missing
159/// path (exploratory guesses are normal for remote models; the errors stay
160/// visible inline and in structured `missing_files`), and returns an honest
161/// `isError: true` only when nothing was read or an error goes beyond a
162/// missing file (security, policy, session).
163#[derive(Debug)]
164pub struct ReadFilesOutcome {
165    pub text: String,
166    pub successful_files: usize,
167    pub files: Vec<ReadFileRevision>,
168    pub errors: Vec<WinxError>,
169}
170
171/// Maximum amount of data to read from a file
172const MAX_FILE_SIZE: u64 = 50_000_000;
173
174fn range_format(start_line_num: Option<usize>, end_line_num: Option<usize>) -> String {
175    let st = start_line_num.map_or(String::new(), |n| n.to_string());
176    let end = end_line_num.map_or(String::new(), |n| n.to_string());
177
178    if st.is_empty() && end.is_empty() {
179        String::new()
180    } else {
181        format!(":{st}-{end}")
182    }
183}
184
185#[instrument(level = "debug", skip(file_path))]
186fn read_file(
187    file_path: &str,
188    max_tokens: Option<usize>,
189    cwd: &Path,
190    workspace_root: &Path,
191    show_line_numbers: bool,
192    start_line_num: Option<usize>,
193    end_line_num: Option<usize>,
194) -> Result<FileReadResult> {
195    let file_path_expanded = expand_user(file_path);
196    let path = if Path::new(&file_path_expanded).is_absolute() {
197        PathBuf::from(&file_path_expanded)
198    } else {
199        cwd.join(&file_path_expanded)
200    };
201
202    if !path.exists() {
203        return Err(WinxError::FileNotFound { path: path.clone() });
204    }
205
206    let path = match validate_path_in_workspace(&path, workspace_root) {
207        Ok(canonical) => canonical,
208        Err(security_err) => {
209            return Err(WinxError::PathSecurityError {
210                path: path.clone(),
211                message: security_err.to_string(),
212            });
213        }
214    };
215
216    if !path.is_file() {
217        return Err(WinxError::FileAccessError {
218            path: path.clone(),
219            message: "Path exists but is not a file".to_string(),
220        });
221    }
222
223    let content = read_file_to_string(&path, MAX_FILE_SIZE)?;
224    let file_hash = hash_content(&content);
225    let lines: Vec<&str> = content.lines().collect();
226    let total_lines = lines.len();
227
228    let start_idx = start_line_num.map_or(0, |n| n.saturating_sub(1).min(lines.len()));
229    let end_idx = end_line_num.map_or(lines.len(), |n| n.min(lines.len()));
230
231    if start_idx > lines.len() || start_idx > end_idx {
232        return Err(ErrorRecovery::param_error(
233            "line_range",
234            &format!("Invalid line range for file with {} lines", lines.len()),
235        ));
236    }
237
238    let effective_start = start_line_num.unwrap_or(1);
239    let effective_end = end_line_num.unwrap_or(total_lines);
240
241    let filtered_lines =
242        if lines.is_empty() { &[] } else { &lines[start_idx..end_idx.min(lines.len())] };
243    let mut result_content = String::new();
244
245    if show_line_numbers {
246        for (i, line) in filtered_lines.iter().enumerate() {
247            let line_num = start_idx + i + 1;
248            let _ = writeln!(result_content, "{line_num} {line}");
249        }
250    } else {
251        for line in filtered_lines {
252            result_content.push_str(line);
253            result_content.push('\n');
254        }
255    }
256
257    let mut truncated = false;
258    let mut last_shown = effective_end;
259    let max_tokens = max_tokens.unwrap_or_else(|| select_max_tokens(file_path));
260    // Byte-level BPE emits at most one token per input byte. Small payloads are
261    // therefore proven to fit without initializing the tokenizer; larger ones
262    // still get exact counting and token-boundary truncation.
263    let (tokens_count, token_ids) =
264        if crate::utils::encoder::definitely_fits_token_budget(&result_content, max_tokens) {
265            (crate::utils::encoder::estimate_tokens(&result_content), None)
266        } else {
267            let ids = crate::utils::encoder::encode_ids(&result_content);
268            let count = ids
269                .as_ref()
270                .map_or_else(|| crate::utils::encoder::estimate_tokens(&result_content), Vec::len);
271            (count, ids)
272        };
273
274    if tokens_count > max_tokens {
275        truncate_to_token_budget(&mut result_content, max_tokens, token_ids);
276        // Tell the agent exactly where to resume so the tail isn't silently lost.
277        let kept_lines = result_content.lines().count();
278        last_shown = (start_idx + kept_lines).min(total_lines);
279        let resume_from = last_shown + 1;
280        let _ = write!(
281            result_content,
282            "\n(...truncated) Showing up to line {last_shown} of {total_lines} total lines \
283             ({tokens_count} tokens exceeded limit {max_tokens}). Continue reading from line \
284             {resume_from} using the syntax {file_path}:{resume_from}-{total_lines}"
285        );
286        truncated = true;
287    }
288
289    let canon_path = path.to_string_lossy().to_string();
290    let effective_end_line = if truncated { last_shown } else { effective_end };
291
292    Ok(FileReadResult {
293        content: result_content,
294        truncated,
295        canonical_path: canon_path,
296        visible_range: (effective_start, effective_end_line.min(total_lines.max(1))),
297        file_hash,
298        total_lines,
299    })
300}
301
302fn hash_content(content: &str) -> String {
303    let digest = Sha256::digest(content.as_bytes());
304    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
305        let _ = write!(hash, "{byte:02x}");
306        hash
307    })
308}
309
310pub(crate) fn revision_from_hash(hash: &str) -> String {
311    format!("sha256:{hash}")
312}
313
314fn truncate_to_token_budget(content: &mut String, max_tokens: usize, ids: Option<Vec<u32>>) {
315    // `ids` were already computed by the caller (the token count needs them too),
316    // so reuse them instead of re-encoding the whole string a second time here.
317    let Some(ids) = ids else {
318        // No tokenizer available: fall back to a char-count cut.
319        let byte_idx = byte_index_for_char_count(content, max_tokens);
320        content.truncate(byte_idx);
321        trim_to_last_line_boundary(content);
322        return;
323    };
324
325    if ids.len() <= max_tokens {
326        return;
327    }
328
329    if let Some(decoded) = crate::utils::encoder::decode_ids(&ids[..max_tokens]) {
330        *content = decoded;
331    } else {
332        let byte_idx = byte_index_for_char_count(content, max_tokens);
333        content.truncate(byte_idx);
334    }
335    trim_to_last_line_boundary(content);
336}
337
338fn trim_to_last_line_boundary(content: &mut String) {
339    if let Some(last_nl) = content.rfind('\n') {
340        content.truncate(last_nl + 1);
341    } else {
342        // A partial first line is not safe to record as read: the continuation
343        // would skip its unseen suffix. Keep no content/range and resume from
344        // the same line instead.
345        content.clear();
346    }
347}
348
349fn byte_index_for_char_count(content: &str, char_count: usize) -> usize {
350    content.char_indices().nth(char_count).map_or(content.len(), |(idx, _)| idx)
351}
352
353fn select_max_tokens(file_path: &str) -> usize {
354    // Budgets are overridable per deployment via env vars so large-context
355    // clients can pull more of each file into context (defaults match wcgw).
356    if is_source_code_file(file_path) {
357        crate::utils::encoder::budget_from_env("WINX_CODING_TOKEN_BUDGET", CODING_MAX_TOKENS)
358    } else {
359        crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", NONCODING_MAX_TOKENS)
360    }
361}
362
363pub(crate) fn configured_parallelism() -> usize {
364    parse_read_parallelism(crate::config::env_text("WINX_READ_PARALLELISM").as_deref())
365}
366
367fn parse_read_parallelism(value: Option<&str>) -> usize {
368    value
369        .and_then(|value| value.parse::<usize>().ok())
370        .filter(|parallelism| *parallelism > 0)
371        .unwrap_or(DEFAULT_READ_PARALLELISM)
372        .min(MAX_READ_PARALLELISM)
373}
374
375fn is_source_code_file(file_path: &str) -> bool {
376    let path = Path::new(file_path);
377    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
378    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
379
380    matches!(file_name, "Makefile" | "Dockerfile" | "Jenkinsfile")
381        || matches!(
382            extension,
383            "py" | "pyx"
384                | "pyi"
385                | "pyw"
386                | "js"
387                | "jsx"
388                | "ts"
389                | "tsx"
390                | "mjs"
391                | "cjs"
392                | "html"
393                | "css"
394                | "scss"
395                | "sass"
396                | "less"
397                | "c"
398                | "h"
399                | "cpp"
400                | "cxx"
401                | "cc"
402                | "hpp"
403                | "java"
404                | "kt"
405                | "go"
406                | "rs"
407                | "rb"
408                | "php"
409                | "sh"
410                | "bash"
411                | "zsh"
412                | "sql"
413                | "xml"
414                | "json"
415                | "yaml"
416                | "yml"
417                | "toml"
418                | "md"
419                | "ex"
420                | "exs"
421        )
422}
423
424async fn execute_read_requests(
425    requests: &[FileReadRequest],
426    cwd: &Path,
427    workspace_root: &Path,
428    show_line_numbers: bool,
429) -> ReadBatchState {
430    let mut state = ReadBatchState::default();
431
432    // Files are independent, so perform blocking filesystem/tokenizer work in a
433    // bounded pool. Results are still consumed in request order, preserving the
434    // stable response and the rule that a truncated file stops the visible batch.
435    'batches: for batch in requests.chunks(configured_parallelism()) {
436        let mut tasks = Vec::with_capacity(batch.len());
437        for request in batch.iter().cloned() {
438            let worker_path = request.clean_path.clone();
439            let worker_cwd = cwd.to_path_buf();
440            let worker_root = workspace_root.to_path_buf();
441            let start_line_num = request.start_line_num;
442            let end_line_num = request.end_line_num;
443            let task = tokio::task::spawn_blocking(move || {
444                read_file(
445                    &worker_path,
446                    Some(select_max_tokens(&worker_path)),
447                    &worker_cwd,
448                    &worker_root,
449                    show_line_numbers,
450                    start_line_num,
451                    end_line_num,
452                )
453            });
454            tasks.push((request, task));
455        }
456
457        let mut tasks = tasks.into_iter();
458        while let Some((request, task)) = tasks.next() {
459            let result = task.await.unwrap_or_else(|error| {
460                Err(WinxError::CommandExecutionError(format!(
461                    "ReadFiles worker failed for {}: {error}",
462                    request.clean_path
463                )))
464            });
465            if state.record_result(&request, result, requests.len()) {
466                // Running blocking jobs cannot be cancelled, but abort prevents
467                // queued work from starting. Only returned content is guarded.
468                for (_, pending) in tasks {
469                    pending.abort();
470                }
471                break 'batches;
472            }
473        }
474    }
475    state
476}
477
478async fn persist_read_stats(workspace_root: &Path, stats_paths: Vec<PathBuf>) {
479    if stats_paths.is_empty() {
480        return;
481    }
482    let stats_root = workspace_root.to_path_buf();
483    let result = tokio::task::spawn_blocking(move || {
484        crate::utils::workspace_stats::record_reads(&stats_root, &stats_paths)
485    })
486    .await;
487    match result {
488        Ok(Err(error)) => debug!("failed to record read stats: {error}"),
489        Err(error) => debug!("read stats worker failed: {error}"),
490        Ok(Ok(())) => {}
491    }
492}
493
494pub async fn handle_tool_call(
495    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
496    read_files: ReadFiles,
497) -> Result<String> {
498    let outcome = handle_tool_call_detailed(bash_state_arc, read_files).await?;
499    if outcome.successful_files == 0 {
500        if let Some(error) = outcome.errors.into_iter().next() {
501            return Err(error);
502        }
503    }
504    Ok(outcome.text)
505}
506
507pub async fn handle_tool_call_detailed(
508    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
509    read_files: ReadFiles,
510) -> Result<ReadFilesOutcome> {
511    let (cwd, workspace_root) = {
512        let bash_state_guard = bash_state_arc.lock().await;
513        let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
514        (bash_state.cwd.clone(), bash_state.workspace_root.clone())
515    };
516
517    let requests = read_files
518        .file_paths
519        .iter()
520        .enumerate()
521        .map(|(index, file_path)| FileReadRequest {
522            index,
523            requested_path: file_path.clone(),
524            clean_path: read_files.get_clean_path(index),
525            start_line_num: read_files.start_line_nums.get(index).copied().flatten(),
526            end_line_num: read_files.end_line_nums.get(index).copied().flatten(),
527        })
528        .collect::<Vec<_>>();
529    let batch =
530        execute_read_requests(&requests, &cwd, &workspace_root, read_files.show_line_numbers())
531            .await;
532    let ReadBatchState { message, file_ranges, stats_paths, successful_files, revisions, errors } =
533        batch;
534    persist_read_stats(&workspace_root, stats_paths).await;
535
536    let mut bash_state_guard = bash_state_arc.lock().await;
537    if let Some(bash_state) = bash_state_guard.as_mut() {
538        for (path, (ranges, file_hash, total_lines)) in file_ranges {
539            bash_state.record_read_coverage(&path, ranges, file_hash, total_lines);
540        }
541    }
542
543    Ok(ReadFilesOutcome { text: message, successful_files, files: revisions, errors })
544}
545
546#[cfg(test)]
547mod tests {
548    use std::fmt::Write as _;
549
550    use super::{parse_read_parallelism, read_file, trim_to_last_line_boundary};
551    use crate::state::bash_state::FileWhitelistData;
552
553    #[test]
554    fn token_cut_never_exposes_a_partial_line() {
555        let mut content = "complete line\npartial line suffix".to_string();
556        content.truncate("complete line\npartial".len());
557        trim_to_last_line_boundary(&mut content);
558        assert_eq!(content, "complete line\n");
559
560        let mut first_line_only = "partial first line".to_string();
561        first_line_only.truncate(7);
562        trim_to_last_line_boundary(&mut first_line_only);
563        assert!(first_line_only.is_empty(), "expected empty first_line_only");
564    }
565
566    #[test]
567    fn token_truncation_whitelists_only_complete_visible_lines() -> anyhow::Result<()> {
568        let temp = tempfile::tempdir()?;
569        let path = temp.path().join("large.txt");
570        let mut source = String::new();
571        for line in 1..=200 {
572            writeln!(source, "line {line}: enough repeated content to consume tokens quickly")?;
573        }
574        std::fs::write(&path, source)?;
575        let path = path.to_str().ok_or_else(|| anyhow::anyhow!("fixture path is not UTF-8"))?;
576
577        let result = read_file(path, Some(50), temp.path(), temp.path(), false, None, None)?;
578
579        assert!(result.truncated);
580        let visible = result
581            .content
582            .split_once("\n(...truncated)")
583            .map(|(visible, _)| visible)
584            .ok_or_else(|| anyhow::anyhow!("truncation marker is missing"))?;
585        let visible_lines = visible.lines().count();
586        assert_eq!(result.visible_range, (1, visible_lines));
587        let coverage = FileWhitelistData::new(
588            result.file_hash,
589            vec![result.visible_range],
590            result.total_lines,
591        );
592        assert!(!coverage.is_read_enough());
593        assert_eq!(coverage.get_unread_ranges(), vec![(visible_lines + 1, result.total_lines)]);
594        Ok(())
595    }
596
597    #[test]
598    fn oversized_first_line_records_no_read_coverage() -> anyhow::Result<()> {
599        let temp = tempfile::tempdir()?;
600        let path = temp.path().join("single-line.txt");
601        std::fs::write(&path, "x".repeat(10_000))?;
602        let path = path.to_str().ok_or_else(|| anyhow::anyhow!("fixture path is not UTF-8"))?;
603
604        let result = read_file(path, Some(10), temp.path(), temp.path(), false, None, None)?;
605
606        assert!(result.truncated);
607        assert!(result.content.starts_with("\n(...truncated) Showing up to line 0"));
608        assert_eq!(result.visible_range, (1, 0));
609        let coverage = FileWhitelistData::new(
610            result.file_hash,
611            vec![result.visible_range],
612            result.total_lines,
613        );
614        assert!(coverage.line_ranges_read.is_empty(), "expected empty coverage.line_ranges_read");
615        assert_eq!(coverage.get_unread_ranges(), vec![(1, 1)]);
616        Ok(())
617    }
618
619    #[test]
620    fn parallelism_uses_safe_defaults_and_bounds() {
621        assert_eq!(parse_read_parallelism(None), 4);
622        assert_eq!(parse_read_parallelism(Some("invalid")), 4);
623        assert_eq!(parse_read_parallelism(Some("0")), 4);
624        assert_eq!(parse_read_parallelism(Some("1")), 1);
625        assert_eq!(parse_read_parallelism(Some("8")), 8);
626        assert_eq!(parse_read_parallelism(Some("999")), 32);
627    }
628}