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;
24
25/// Type alias for file reading result
26type FileReadResult = (String, bool, usize, String, (usize, usize), String, usize);
27type ReadCoverage = (Vec<(usize, usize)>, String, usize);
28
29/// Maximum amount of data to read from a file
30const MAX_FILE_SIZE: u64 = 50_000_000;
31
32fn range_format(start_line_num: Option<usize>, end_line_num: Option<usize>) -> String {
33    let st = start_line_num.map_or(String::new(), |n| n.to_string());
34    let end = end_line_num.map_or(String::new(), |n| n.to_string());
35
36    if st.is_empty() && end.is_empty() {
37        String::new()
38    } else {
39        format!(":{st}-{end}")
40    }
41}
42
43#[instrument(level = "debug", skip(file_path))]
44async fn read_file(
45    file_path: &str,
46    max_tokens: Option<usize>,
47    cwd: &Path,
48    workspace_root: &Path,
49    show_line_numbers: bool,
50    start_line_num: Option<usize>,
51    end_line_num: Option<usize>,
52) -> Result<FileReadResult> {
53    let file_path_expanded = expand_user(file_path);
54    let path = if Path::new(&file_path_expanded).is_absolute() {
55        PathBuf::from(&file_path_expanded)
56    } else {
57        cwd.join(&file_path_expanded)
58    };
59
60    if !path.exists() {
61        return Err(WinxError::FileAccessError {
62            path: path.clone(),
63            message: "File does not exist".to_string(),
64        });
65    }
66
67    let path = match validate_path_in_workspace(&path, workspace_root) {
68        Ok(canonical) => canonical,
69        Err(security_err) => {
70            return Err(WinxError::PathSecurityError {
71                path: path.clone(),
72                message: security_err.to_string(),
73            });
74        }
75    };
76
77    if !path.is_file() {
78        return Err(WinxError::FileAccessError {
79            path: path.clone(),
80            message: "Path exists but is not a file".to_string(),
81        });
82    }
83
84    let content = read_file_to_string(&path, MAX_FILE_SIZE)?;
85    let file_hash = hash_content(&content);
86    let lines: Vec<&str> = content.lines().collect();
87    let total_lines = lines.len();
88
89    let start_idx = start_line_num.map_or(0, |n| n.saturating_sub(1).min(lines.len()));
90    let end_idx = end_line_num.map_or(lines.len(), |n| n.min(lines.len()));
91
92    if start_idx > lines.len() || start_idx > end_idx {
93        return Err(ErrorRecovery::param_error(
94            "line_range",
95            &format!("Invalid line range for file with {} lines", lines.len()),
96        ));
97    }
98
99    let effective_start = start_line_num.unwrap_or(1);
100    let effective_end = end_line_num.unwrap_or(total_lines);
101
102    let filtered_lines =
103        if lines.is_empty() { &[] } else { &lines[start_idx..end_idx.min(lines.len())] };
104    let mut result_content = String::new();
105
106    if show_line_numbers {
107        for (i, line) in filtered_lines.iter().enumerate() {
108            let line_num = start_idx + i + 1;
109            let _ = writeln!(result_content, "{line_num} {line}");
110        }
111    } else {
112        for line in filtered_lines {
113            result_content.push_str(line);
114            result_content.push('\n');
115        }
116    }
117
118    let mut truncated = false;
119    let max_tokens = max_tokens.unwrap_or_else(|| select_max_tokens(file_path));
120    // Byte-level BPE emits at most one token per input byte. Small payloads are
121    // therefore proven to fit without initializing the tokenizer; larger ones
122    // still get exact counting and token-boundary truncation.
123    let (tokens_count, token_ids) =
124        if crate::utils::encoder::definitely_fits_token_budget(&result_content, max_tokens) {
125            (crate::utils::encoder::estimate_tokens(&result_content), None)
126        } else {
127            let ids = crate::utils::encoder::encode_ids(&result_content);
128            let count = ids
129                .as_ref()
130                .map_or_else(|| crate::utils::encoder::estimate_tokens(&result_content), Vec::len);
131            (count, ids)
132        };
133
134    if tokens_count > max_tokens {
135        truncate_to_token_budget(&mut result_content, max_tokens, token_ids);
136        // Tell the agent exactly where to resume so the tail isn't silently lost.
137        let kept_lines = result_content.lines().count();
138        let last_shown = (start_idx + kept_lines).min(total_lines);
139        let resume_from = last_shown + 1;
140        let _ = write!(
141            result_content,
142            "\n(...truncated) Showing up to line {last_shown} of {total_lines} total lines \
143             ({tokens_count} tokens exceeded limit {max_tokens}). Continue reading from line \
144             {resume_from} using the syntax {file_path}:{resume_from}-{total_lines}"
145        );
146        truncated = true;
147    }
148
149    let canon_path = path.to_string_lossy().to_string();
150
151    Ok((
152        result_content,
153        truncated,
154        tokens_count,
155        canon_path,
156        (effective_start, effective_end.min(total_lines.max(1))),
157        file_hash,
158        total_lines,
159    ))
160}
161
162fn hash_content(content: &str) -> String {
163    let digest = Sha256::digest(content.as_bytes());
164    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
165        let _ = write!(hash, "{byte:02x}");
166        hash
167    })
168}
169
170fn truncate_to_token_budget(content: &mut String, max_tokens: usize, ids: Option<Vec<u32>>) {
171    // `ids` were already computed by the caller (the token count needs them too),
172    // so reuse them instead of re-encoding the whole string a second time here.
173    let Some(ids) = ids else {
174        // No tokenizer available: fall back to a char-count cut.
175        let byte_idx = byte_index_for_char_count(content, max_tokens);
176        content.truncate(byte_idx);
177        return;
178    };
179
180    if ids.len() <= max_tokens {
181        return;
182    }
183
184    if let Some(decoded) = crate::utils::encoder::decode_ids(&ids[..max_tokens]) {
185        *content = decoded;
186    } else {
187        let byte_idx = byte_index_for_char_count(content, max_tokens);
188        content.truncate(byte_idx);
189    }
190}
191
192fn byte_index_for_char_count(content: &str, char_count: usize) -> usize {
193    content.char_indices().nth(char_count).map_or(content.len(), |(idx, _)| idx)
194}
195
196fn select_max_tokens(file_path: &str) -> usize {
197    // Budgets are overridable per deployment via env vars so large-context
198    // clients can pull more of each file into context (defaults match wcgw).
199    if is_source_code_file(file_path) {
200        crate::utils::encoder::budget_from_env("WINX_CODING_TOKEN_BUDGET", CODING_MAX_TOKENS)
201    } else {
202        crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", NONCODING_MAX_TOKENS)
203    }
204}
205
206fn is_source_code_file(file_path: &str) -> bool {
207    let path = Path::new(file_path);
208    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
209    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
210
211    matches!(file_name, "Makefile" | "Dockerfile" | "Jenkinsfile")
212        || matches!(
213            extension,
214            "py" | "pyx"
215                | "pyi"
216                | "pyw"
217                | "js"
218                | "jsx"
219                | "ts"
220                | "tsx"
221                | "mjs"
222                | "cjs"
223                | "html"
224                | "css"
225                | "scss"
226                | "sass"
227                | "less"
228                | "c"
229                | "h"
230                | "cpp"
231                | "cxx"
232                | "cc"
233                | "hpp"
234                | "java"
235                | "kt"
236                | "go"
237                | "rs"
238                | "rb"
239                | "php"
240                | "sh"
241                | "bash"
242                | "zsh"
243                | "sql"
244                | "xml"
245                | "json"
246                | "yaml"
247                | "yml"
248                | "toml"
249                | "md"
250                | "ex"
251                | "exs"
252        )
253}
254
255pub async fn handle_tool_call(
256    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
257    read_files: ReadFiles,
258) -> Result<String> {
259    let (cwd, workspace_root) = {
260        let bash_state_guard = bash_state_arc.lock().await;
261        let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
262        (bash_state.cwd.clone(), bash_state.workspace_root.clone())
263    };
264
265    let mut message = String::new();
266    let mut file_ranges_dict: HashMap<String, ReadCoverage> = HashMap::new();
267
268    for (index, file_path) in read_files.file_paths.iter().enumerate() {
269        let clean_path = read_files.get_clean_path(index);
270        let start_line_num = read_files.start_line_nums.get(index).copied().flatten();
271        let end_line_num = read_files.end_line_nums.get(index).copied().flatten();
272
273        match read_file(
274            &clean_path,
275            Some(select_max_tokens(&clean_path)),
276            &cwd,
277            &workspace_root,
278            read_files.show_line_numbers(),
279            start_line_num,
280            end_line_num,
281        )
282        .await
283        {
284            Ok((content, truncated, _, canon_path, line_range, file_hash, total_lines)) => {
285                let entry = file_ranges_dict
286                    .entry(canon_path.clone())
287                    .or_insert_with(|| (Vec::new(), file_hash.clone(), total_lines));
288                entry.0.push(line_range);
289                entry.1 = file_hash;
290                entry.2 = total_lines;
291                let _ = write!(
292                    message,
293                    "\n{}{}\n```\n{content}\n```",
294                    clean_path,
295                    range_format(start_line_num, end_line_num)
296                );
297
298                if let Err(e) = crate::utils::workspace_stats::record_read(
299                    &workspace_root,
300                    Path::new(&canon_path),
301                ) {
302                    debug!("failed to record read stats: {e}");
303                }
304
305                if truncated {
306                    let remaining = read_files.file_paths.len().saturating_sub(index + 1);
307                    if remaining > 0 {
308                        let _ = write!(
309                            message,
310                            "\n\n(Not reading the remaining {remaining} file(s) due to the token \
311                             limit. Call ReadFiles again for them.)"
312                        );
313                    }
314                    break;
315                }
316            }
317            Err(e) => {
318                let _ = write!(message, "\nError reading {file_path}: {e}");
319            }
320        }
321    }
322
323    let mut bash_state_guard = bash_state_arc.lock().await;
324    if let Some(bash_state) = bash_state_guard.as_mut() {
325        for (path, (ranges, file_hash, total_lines)) in file_ranges_dict {
326            bash_state
327                .whitelist_for_overwrite
328                .entry(path)
329                .and_modify(|existing| {
330                    existing.file_hash.clone_from(&file_hash);
331                    existing.total_lines = total_lines;
332                    // Merge (not extend): re-reading a file must not append duplicate
333                    // ranges forever. Keeps line_ranges_read bounded + disjoint.
334                    existing.merge_ranges(ranges.iter().copied());
335                })
336                .or_insert_with(|| {
337                    crate::state::bash_state::FileWhitelistData::new(file_hash, ranges, total_lines)
338                });
339        }
340    }
341
342    Ok(message)
343}