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