Skip to main content

winx_code_agent/tools/
read_files.rs

1//! Implementation of the `ReadFiles` tool.
2//!
3//! Reads one or many files with line numbers and optional line ranges. Every
4//! call is bounded by a response budget ([`ReadResponseBudget`]): an item over
5//! budget returns its head at a line boundary plus the exact next range to
6//! request, and an unchanged, still-guarded repeat in the same thread returns a
7//! compact marker instead of resending identical content.
8
9use serde::Serialize;
10use sha2::{Digest, Sha256};
11use std::collections::HashMap;
12use std::fmt::Write as FmtWrite;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::{Instant, SystemTime};
16use tokio::sync::Mutex;
17use tracing::{debug, instrument};
18
19use crate::config::{ReadResponseBudget, MIN_READ_BUDGET_BYTES};
20use crate::errors::{ErrorRecovery, Result, WinxError};
21use crate::state::bash_state::{BashState, ReadDelivery};
22use crate::types::ReadFiles;
23use crate::utils::mmap::read_file_to_string;
24use crate::utils::path::{expand_user, validate_path_in_workspace};
25
26/// Default token limits for file reading
27const CODING_MAX_TOKENS: usize = 24_000;
28const NONCODING_MAX_TOKENS: usize = 8_000;
29const DEFAULT_READ_PARALLELISM: usize = 4;
30const MAX_READ_PARALLELISM: usize = 32;
31/// Repeats smaller than this are resent: the marker would save too little to
32/// justify a possible `force=true` round trip.
33const MIN_UNCHANGED_ELISION_BYTES: usize = 512;
34/// Above this multiple of the chunk budget, the continuation also points at
35/// `CodeMap`, which navigates a large file far more cheaply than paging.
36const OUTLINE_HINT_CHUNKS: usize = 4;
37
38type ReadCoverage = (Vec<(usize, usize)>, String, usize);
39
40/// Byte limits one worker applies while rendering. `render` bounds the text it
41/// formats and tokenizes; `horizon` bounds the line sizes it measures so the
42/// ordered phase can size the next chunk without rendering it.
43#[derive(Clone, Copy, Debug)]
44struct WorkerCaps {
45    render: Option<usize>,
46    horizon: usize,
47}
48
49impl WorkerCaps {
50    const UNBOUNDED: Self = Self { render: None, horizon: 0 };
51}
52
53#[derive(Debug)]
54struct FileReadResult {
55    /// Complete numbered lines starting at `first_line`.
56    content: String,
57    /// Cumulative rendered byte end of each requested line from `first_line`,
58    /// measured up to the worker horizon (it may run past `content`).
59    line_ends: Vec<usize>,
60    first_line: usize,
61    /// Last requested line, clamped to the file.
62    last_requested_line: usize,
63    /// Rendered size of the whole requested range, as if nothing were cut.
64    requested_bytes: usize,
65    /// `(tokens, limit)` when the per-file token budget cut `content`.
66    token_limit: Option<(usize, usize)>,
67    canonical_path: String,
68    file_hash: String,
69    total_lines: usize,
70}
71
72/// Revision receipt for one successful file read. `revision` is opaque to MCP
73/// callers and may be supplied to `ApplyPatch` as a compare-and-swap guard.
74#[derive(Clone, Debug, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct ReadFileRevision {
77    pub path: String,
78    pub revision: String,
79    pub total_lines: usize,
80    pub visible_ranges: Vec<ReadLineRange>,
81    pub truncated: bool,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub next_start_line: Option<usize>,
84    /// Inclusive end of a continuation chunk that fits the per-file budget.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub next_end_line: Option<usize>,
87    /// Content was elided because this thread already holds it unchanged.
88    #[serde(skip_serializing_if = "std::ops::Not::not")]
89    pub unchanged: bool,
90    #[serde(rename = "temporary_artifact", skip_serializing_if = "Option::is_none")]
91    pub temporary_artifact: Option<crate::utils::agent_temp::release::TemporaryArtifactMetadata>,
92}
93
94#[derive(Clone, Copy, Debug, Serialize)]
95#[serde(rename_all = "camelCase")]
96pub struct ReadLineRange {
97    pub start_line: usize,
98    pub end_line: usize,
99}
100
101/// Budget accounting for one call. Byte counts are rendered (numbered) text.
102#[derive(Clone, Debug, Default, Serialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ReadBudgetReport {
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub call_bytes: Option<usize>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub file_bytes: Option<usize>,
109    pub returned_bytes: usize,
110    pub truncated_files: usize,
111    pub unchanged_files: usize,
112    /// Requested bytes withheld by truncation.
113    pub omitted_bytes: usize,
114    /// Bytes not resent because the content was unchanged.
115    pub unchanged_bytes: usize,
116    /// Exact `file_paths` entries not read because the call budget ran out.
117    #[serde(skip_serializing_if = "Vec::is_empty")]
118    pub deferred: Vec<String>,
119}
120
121impl ReadBudgetReport {
122    /// Whether the budget changed this response; otherwise it is not reported.
123    pub fn is_notable(&self) -> bool {
124        self.truncated_files > 0 || self.unchanged_files > 0 || !self.deferred.is_empty()
125    }
126}
127
128#[derive(Clone)]
129struct FileReadRequest {
130    index: usize,
131    requested_path: String,
132    clean_path: String,
133    start_line_num: Option<usize>,
134    end_line_num: Option<usize>,
135}
136
137impl FileReadRequest {
138    /// Explicit `start-end` (or `-end`) ranges are deliberate and exempt from
139    /// the per-file cap; whole-file and open-ended reads are not.
140    fn is_bounded(&self) -> bool {
141        self.end_line_num.is_some()
142    }
143
144    fn spec(&self) -> String {
145        format!("{}{}", self.clean_path, range_format(self.start_line_num, self.end_line_num))
146    }
147}
148
149/// A complete requested range to remember for unchanged-repeat compaction.
150struct DeliveredRange {
151    path: String,
152    start_line: Option<usize>,
153    end_line: Option<usize>,
154    file_hash: String,
155}
156
157/// Coverage that an elided repeat re-confirms. It is applied only while the
158/// guard still holds the same hash, so it can refresh but never re-grant a
159/// permit that a concurrent SEARCH conflict revoked.
160struct RefreshedCoverage {
161    path: String,
162    range: (usize, usize),
163    file_hash: String,
164}
165
166struct ReadBudgetState {
167    budget: ReadResponseBudget,
168    remaining: Option<usize>,
169    unchanged_candidates: Vec<ReadDelivery>,
170    report: ReadBudgetReport,
171}
172
173impl ReadBudgetState {
174    fn new(budget: ReadResponseBudget, unchanged_candidates: Vec<ReadDelivery>) -> Self {
175        Self {
176            budget,
177            remaining: budget.call_bytes,
178            unchanged_candidates,
179            report: ReadBudgetReport {
180                call_bytes: budget.call_bytes,
181                file_bytes: budget.file_bytes,
182                ..ReadBudgetReport::default()
183            },
184        }
185    }
186
187    /// Size used to recommend a continuation chunk.
188    fn chunk_bytes(&self) -> Option<usize> {
189        self.budget.file_bytes.or(self.budget.call_bytes)
190    }
191
192    /// Worker caps for one request before any batch item has been consumed.
193    fn worker_caps(&self, request: &FileReadRequest) -> WorkerCaps {
194        let render = if request.is_bounded() {
195            self.budget.call_bytes
196        } else {
197            min_option(self.budget.file_bytes, self.budget.call_bytes)
198        };
199        render.map_or(WorkerCaps::UNBOUNDED, |render| WorkerCaps {
200            render: Some(render),
201            horizon: render.saturating_add(self.chunk_bytes().unwrap_or(render)),
202        })
203    }
204
205    fn unchanged_delivery(
206        &self,
207        request: &FileReadRequest,
208        result: &FileReadResult,
209    ) -> Option<SystemTime> {
210        if result.requested_bytes < MIN_UNCHANGED_ELISION_BYTES {
211            return None;
212        }
213        self.unchanged_candidates
214            .iter()
215            .find(|delivery| {
216                delivery.path == result.canonical_path
217                    && delivery.start_line == request.start_line_num
218                    && delivery.end_line == request.end_line_num
219                    && delivery.file_hash == result.file_hash
220            })
221            .map(|delivery| delivery.delivered_at)
222    }
223}
224
225fn min_option(left: Option<usize>, right: Option<usize>) -> Option<usize> {
226    match (left, right) {
227        (Some(left), Some(right)) => Some(left.min(right)),
228        (value, None) | (None, value) => value,
229    }
230}
231
232struct ReadBatchState {
233    message: String,
234    file_ranges: HashMap<String, ReadCoverage>,
235    refreshed: Vec<RefreshedCoverage>,
236    delivered: Vec<DeliveredRange>,
237    stats_paths: Vec<PathBuf>,
238    successful_files: usize,
239    revisions: Vec<ReadFileRevision>,
240    errors: Vec<WinxError>,
241    budget: ReadBudgetState,
242}
243
244impl ReadBatchState {
245    fn new(budget: ReadBudgetState) -> Self {
246        Self {
247            message: String::new(),
248            file_ranges: HashMap::new(),
249            refreshed: Vec::new(),
250            delivered: Vec::new(),
251            stats_paths: Vec::new(),
252            successful_files: 0,
253            revisions: Vec::new(),
254            errors: Vec::new(),
255            budget,
256        }
257    }
258
259    /// Add one worker result to the ordered response. Returns `true` when the
260    /// call budget is exhausted and the remaining items must be deferred.
261    fn record_result(&mut self, request: &FileReadRequest, result: Result<FileReadResult>) -> bool {
262        match result {
263            Ok(result) => {
264                if let Some(delivered_at) = self.budget.unchanged_delivery(request, &result) {
265                    self.record_unchanged(request, &result, delivered_at);
266                    return false;
267                }
268                self.record_content(request, result)
269            }
270            Err(error) => {
271                let _ = write!(self.message, "\nError reading {}: {error}", request.requested_path);
272                self.errors.push(error);
273                false
274            }
275        }
276    }
277
278    fn record_unchanged(
279        &mut self,
280        request: &FileReadRequest,
281        result: &FileReadResult,
282        delivered_at: SystemTime,
283    ) {
284        let revision = revision_from_hash(&result.file_hash);
285        let range = (result.first_line, result.last_requested_line);
286        self.successful_files = self.successful_files.saturating_add(1);
287        self.budget.report.unchanged_files = self.budget.report.unchanged_files.saturating_add(1);
288        self.budget.report.unchanged_bytes =
289            self.budget.report.unchanged_bytes.saturating_add(result.requested_bytes);
290        self.refreshed.push(RefreshedCoverage {
291            path: result.canonical_path.clone(),
292            range,
293            file_hash: result.file_hash.clone(),
294        });
295        self.revisions.push(ReadFileRevision {
296            path: result.canonical_path.clone(),
297            revision: revision.clone(),
298            total_lines: result.total_lines,
299            visible_ranges: visible_ranges(range),
300            truncated: false,
301            next_start_line: None,
302            next_end_line: None,
303            unchanged: true,
304            temporary_artifact: None,
305        });
306        let _ = write!(
307            self.message,
308            "\n{}\n[unchanged: identical to the copy already returned in full at {} \
309             (revision {revision}); lines {}-{} remain valid edit evidence. Re-request with \
310             force=true only if that copy is no longer in your context.]",
311            request.spec(),
312            describe_delivery_time(delivered_at),
313            range.0,
314            range.1,
315        );
316        self.stats_paths.push(PathBuf::from(&result.canonical_path));
317    }
318
319    #[expect(clippy::too_many_lines, reason = "one ordered budget decision per item")]
320    fn record_content(&mut self, request: &FileReadRequest, result: FileReadResult) -> bool {
321        let per_file = if request.is_bounded() { None } else { self.budget.budget.file_bytes };
322        let allowed = min_option(per_file, self.budget.remaining);
323        let call_limited = self.budget.remaining.is_some_and(|remaining| {
324            per_file.is_none_or(|file| remaining < file) && result.content.len() > remaining
325        });
326        let (kept_lines, kept_bytes) = visible_prefix(&result.content, allowed);
327        let shown_something = self.budget.report.returned_bytes > 0;
328        if call_limited
329            && shown_something
330            && (kept_lines == 0 || allowed.is_some_and(|bytes| bytes < MIN_READ_BUDGET_BYTES))
331        {
332            // Only a sliver would fit after earlier items: defer the whole
333            // item so the next call can return it intact.
334            self.budget.report.deferred.push(request.spec());
335            return true;
336        }
337
338        let FileReadResult {
339            content,
340            line_ends,
341            first_line,
342            last_requested_line,
343            requested_bytes,
344            token_limit,
345            canonical_path,
346            file_hash,
347            total_lines,
348            ..
349        } = result;
350        let next_start = first_line.saturating_add(kept_lines);
351        let truncated = next_start <= last_requested_line;
352        let visible = (first_line, next_start.saturating_sub(1));
353        let revision = revision_from_hash(&file_hash);
354        let next_end = truncated.then(|| {
355            next_chunk_end(&line_ends, kept_lines, kept_bytes, self.budget.chunk_bytes())
356                .map_or(last_requested_line, |line| first_line.saturating_add(line))
357                .clamp(next_start, last_requested_line)
358        });
359
360        self.successful_files = self.successful_files.saturating_add(1);
361        let entry = self
362            .file_ranges
363            .entry(canonical_path.clone())
364            .or_insert_with(|| (Vec::new(), file_hash.clone(), total_lines));
365        if entry.1 != file_hash || entry.2 != total_lines {
366            // The same path can be requested more than once in one batch. If
367            // it changed between reads, keep coverage only for the version
368            // whose hash will guard the next edit.
369            *entry = (Vec::new(), file_hash.clone(), total_lines);
370        }
371        entry.0.push(visible);
372        if truncated {
373            self.budget.report.truncated_files =
374                self.budget.report.truncated_files.saturating_add(1);
375            self.budget.report.omitted_bytes = self
376                .budget
377                .report
378                .omitted_bytes
379                .saturating_add(requested_bytes.saturating_sub(kept_bytes));
380        } else {
381            self.delivered.push(DeliveredRange {
382                path: canonical_path.clone(),
383                start_line: request.start_line_num,
384                end_line: request.end_line_num,
385                file_hash: file_hash.clone(),
386            });
387        }
388        self.budget.report.returned_bytes =
389            self.budget.report.returned_bytes.saturating_add(kept_bytes);
390        if let Some(remaining) = self.budget.remaining.as_mut() {
391            *remaining = remaining.saturating_sub(kept_bytes);
392        }
393        self.revisions.push(ReadFileRevision {
394            path: canonical_path.clone(),
395            revision: revision.clone(),
396            total_lines,
397            visible_ranges: visible_ranges(visible),
398            truncated,
399            next_start_line: truncated.then_some(next_start),
400            next_end_line: next_end,
401            unchanged: false,
402            temporary_artifact: None,
403        });
404
405        let _ = write!(self.message, "\n{}\n```\n{}\n```", request.spec(), &content[..kept_bytes]);
406        if let Some(next_end) = next_end {
407            let limit = match token_limit {
408                Some((tokens, max_tokens)) if kept_bytes == content.len() => {
409                    format!("{tokens} tokens exceeded the {max_tokens}-token file limit")
410                }
411                _ => format!("{}-byte response budget", allowed.unwrap_or(kept_bytes)),
412            };
413            let shown = if kept_lines == 0 {
414                format!("line {next_start} alone exceeds the {limit}")
415            } else {
416                format!("lines {}-{} of {total_lines} shown ({limit})", visible.0, visible.1)
417            };
418            let outline = self
419                .budget
420                .chunk_bytes()
421                .filter(|chunk| requested_bytes > chunk.saturating_mul(OUTLINE_HINT_CHUNKS))
422                .filter(|_| outline_supported(&request.clean_path))
423                .map_or("", |_| " CodeMap outline maps this file's symbols more cheaply.");
424            let _ = write!(
425                self.message,
426                "\n[truncated: {shown}; {} more bytes requested. Next: ReadFiles \"{}:{next_start}-\
427                 {next_end}\" (revision {revision}).{outline}]",
428                requested_bytes.saturating_sub(kept_bytes),
429                request.clean_path,
430            );
431        }
432        self.stats_paths.push(PathBuf::from(canonical_path));
433        call_limited && truncated
434    }
435}
436
437fn visible_ranges((start, end): (usize, usize)) -> Vec<ReadLineRange> {
438    (start <= end)
439        .then_some(ReadLineRange { start_line: start, end_line: end })
440        .into_iter()
441        .collect()
442}
443
444/// Longest prefix of complete lines that fits `allowed` bytes.
445fn visible_prefix(content: &str, allowed: Option<usize>) -> (usize, usize) {
446    let allowed = allowed.unwrap_or(usize::MAX);
447    let mut lines = 0;
448    let mut bytes = 0;
449    for line in content.split_inclusive('\n') {
450        if bytes + line.len() > allowed {
451            break;
452        }
453        bytes += line.len();
454        lines += 1;
455    }
456    (lines, bytes)
457}
458
459/// Zero-based index (relative to the first requested line) of the last line
460/// of a continuation that fits `chunk` bytes. `None` means "to the end".
461fn next_chunk_end(
462    line_ends: &[usize],
463    kept_lines: usize,
464    kept_bytes: usize,
465    chunk: Option<usize>,
466) -> Option<usize> {
467    let chunk = chunk?;
468    let rest = line_ends.get(kept_lines..).filter(|rest| !rest.is_empty())?;
469    let fitting = rest.partition_point(|end| end.saturating_sub(kept_bytes) <= chunk);
470    // A single oversized line still advances; the bounded follow-up then
471    // reports it explicitly instead of looping on an empty chunk.
472    Some(kept_lines + fitting.max(1) - 1)
473}
474
475fn outline_supported(file_path: &str) -> bool {
476    Path::new(file_path)
477        .extension()
478        .and_then(|extension| extension.to_str())
479        .is_some_and(crate::utils::symbols::supports)
480}
481
482/// UTC wall-clock time plus age, without enabling `time` formatting features.
483fn describe_delivery_time(at: SystemTime) -> String {
484    let utc = time::OffsetDateTime::from(at);
485    let age = SystemTime::now().duration_since(at).unwrap_or_default().as_secs();
486    format!(
487        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z, {}m{:02}s ago",
488        utc.year(),
489        u8::from(utc.month()),
490        utc.day(),
491        utc.hour(),
492        utc.minute(),
493        utc.second(),
494        age / 60,
495        age % 60
496    )
497}
498
499/// Complete result of one batched read. The MCP adapter keeps a batch with
500/// partial successes a SUCCESS when every per-file error is just a missing
501/// path (exploratory guesses are normal for remote models; the errors stay
502/// visible inline and in structured `missing_files`), and returns an honest
503/// `isError: true` only when nothing was read or an error goes beyond a
504/// missing file (security, policy, session).
505#[derive(Debug)]
506pub struct ReadFilesOutcome {
507    pub text: String,
508    pub successful_files: usize,
509    pub files: Vec<ReadFileRevision>,
510    pub errors: Vec<WinxError>,
511    pub budget: ReadBudgetReport,
512}
513
514/// Maximum amount of data to read from a file
515const MAX_FILE_SIZE: u64 = 50_000_000;
516
517fn range_format(start_line_num: Option<usize>, end_line_num: Option<usize>) -> String {
518    let st = start_line_num.map_or(String::new(), |n| n.to_string());
519    let end = end_line_num.map_or(String::new(), |n| n.to_string());
520
521    if st.is_empty() && end.is_empty() {
522        String::new()
523    } else {
524        format!(":{st}-{end}")
525    }
526}
527
528/// Rendered size of one line exactly as `read_file` formats it.
529fn rendered_line_bytes(line_number: usize, line: &str, show_line_numbers: bool) -> usize {
530    let prefix = if show_line_numbers { decimal_digits(line_number) + 1 } else { 0 };
531    prefix + line.len() + 1
532}
533
534fn decimal_digits(mut value: usize) -> usize {
535    let mut digits = 1;
536    while value >= 10 {
537        value /= 10;
538        digits += 1;
539    }
540    digits
541}
542
543#[instrument(level = "debug", skip(file_path))]
544#[expect(clippy::too_many_arguments, reason = "one blocking worker call with explicit inputs")]
545fn read_file(
546    file_path: &str,
547    max_tokens: Option<usize>,
548    cwd: &Path,
549    workspace_root: &Path,
550    show_line_numbers: bool,
551    start_line_num: Option<usize>,
552    end_line_num: Option<usize>,
553    caps: WorkerCaps,
554) -> Result<FileReadResult> {
555    let file_path_expanded = expand_user(file_path);
556    let path = if Path::new(&file_path_expanded).is_absolute() {
557        PathBuf::from(&file_path_expanded)
558    } else {
559        cwd.join(&file_path_expanded)
560    };
561
562    if !path.exists() {
563        return Err(WinxError::FileNotFound { path: path.clone() });
564    }
565
566    let path = match validate_path_in_workspace(&path, workspace_root) {
567        Ok(canonical) => canonical,
568        Err(security_err) => {
569            return Err(WinxError::PathSecurityError {
570                path: path.clone(),
571                message: security_err.to_string(),
572            });
573        }
574    };
575
576    if !path.is_file() {
577        return Err(WinxError::FileAccessError {
578            path: path.clone(),
579            message: "Path exists but is not a file".to_string(),
580        });
581    }
582
583    let content = read_file_to_string(&path, MAX_FILE_SIZE)?;
584    let file_hash = hash_content(&content);
585    let lines: Vec<&str> = content.lines().collect();
586    let total_lines = lines.len();
587
588    let start_idx = start_line_num.map_or(0, |n| n.saturating_sub(1).min(lines.len()));
589    let end_idx = end_line_num.map_or(lines.len(), |n| n.min(lines.len()));
590
591    if start_idx > lines.len() || start_idx > end_idx {
592        return Err(ErrorRecovery::param_error(
593            "line_range",
594            &format!("Invalid line range for file with {} lines", lines.len()),
595        ));
596    }
597
598    let first_line = start_idx + 1;
599    let requested = &lines[start_idx..end_idx];
600    // Render only what the budget can return, but measure every requested line
601    // (cheap arithmetic) so the response reports exact omitted bytes and an
602    // exact next chunk. Huge files are therefore never fully formatted or
603    // tokenized just to be cut afterwards.
604    let render_cap = caps.render.unwrap_or(usize::MAX);
605    let mut result_content = String::new();
606    let mut content_lines = 0;
607    let mut line_ends = Vec::new();
608    let mut requested_bytes = 0_usize;
609    for (offset, line) in requested.iter().enumerate() {
610        let line_number = first_line + offset;
611        requested_bytes = requested_bytes.saturating_add(rendered_line_bytes(
612            line_number,
613            line,
614            show_line_numbers,
615        ));
616        if requested_bytes <= caps.horizon {
617            line_ends.push(requested_bytes);
618        }
619        if requested_bytes <= render_cap && content_lines == offset {
620            if show_line_numbers {
621                let _ = writeln!(result_content, "{line_number} {line}");
622            } else {
623                result_content.push_str(line);
624                result_content.push('\n');
625            }
626            content_lines += 1;
627        }
628    }
629
630    let max_tokens = max_tokens.unwrap_or_else(|| select_max_tokens(file_path));
631    // Byte-level BPE emits at most one token per input byte. Small payloads are
632    // therefore proven to fit without initializing the tokenizer; larger ones
633    // still get exact counting and token-boundary truncation.
634    let (tokens_count, token_ids) =
635        if crate::utils::encoder::definitely_fits_token_budget(&result_content, max_tokens) {
636            (crate::utils::encoder::estimate_tokens(&result_content), None)
637        } else {
638            let ids = crate::utils::encoder::encode_ids(&result_content);
639            let count = ids
640                .as_ref()
641                .map_or_else(|| crate::utils::encoder::estimate_tokens(&result_content), Vec::len);
642            (count, ids)
643        };
644
645    let mut token_limit = None;
646    if tokens_count > max_tokens {
647        truncate_to_token_budget(&mut result_content, max_tokens, token_ids);
648        token_limit = Some((tokens_count, max_tokens));
649    }
650
651    Ok(FileReadResult {
652        content: result_content,
653        line_ends,
654        first_line,
655        last_requested_line: end_idx,
656        requested_bytes,
657        token_limit,
658        canonical_path: path.to_string_lossy().to_string(),
659        file_hash,
660        total_lines,
661    })
662}
663
664fn hash_content(content: &str) -> String {
665    let digest = Sha256::digest(content.as_bytes());
666    digest.iter().fold(String::with_capacity(digest.len() * 2), |mut hash, byte| {
667        let _ = write!(hash, "{byte:02x}");
668        hash
669    })
670}
671
672pub(crate) fn revision_from_hash(hash: &str) -> String {
673    format!("sha256:{hash}")
674}
675
676fn truncate_to_token_budget(content: &mut String, max_tokens: usize, ids: Option<Vec<u32>>) {
677    // `ids` were already computed by the caller (the token count needs them too),
678    // so reuse them instead of re-encoding the whole string a second time here.
679    let Some(ids) = ids else {
680        // No tokenizer available: fall back to a char-count cut.
681        let byte_idx = byte_index_for_char_count(content, max_tokens);
682        content.truncate(byte_idx);
683        trim_to_last_line_boundary(content);
684        return;
685    };
686
687    if ids.len() <= max_tokens {
688        return;
689    }
690
691    if let Some(decoded) = crate::utils::encoder::decode_ids(&ids[..max_tokens]) {
692        *content = decoded;
693    } else {
694        let byte_idx = byte_index_for_char_count(content, max_tokens);
695        content.truncate(byte_idx);
696    }
697    trim_to_last_line_boundary(content);
698}
699
700fn trim_to_last_line_boundary(content: &mut String) {
701    if let Some(last_nl) = content.rfind('\n') {
702        content.truncate(last_nl + 1);
703    } else {
704        // A partial first line is not safe to record as read: the continuation
705        // would skip its unseen suffix. Keep no content/range and resume from
706        // the same line instead.
707        content.clear();
708    }
709}
710
711fn byte_index_for_char_count(content: &str, char_count: usize) -> usize {
712    content.char_indices().nth(char_count).map_or(content.len(), |(idx, _)| idx)
713}
714
715fn select_max_tokens(file_path: &str) -> usize {
716    // Budgets are overridable per deployment via env vars so large-context
717    // clients can pull more of each file into context (defaults match wcgw).
718    if is_source_code_file(file_path) {
719        crate::utils::encoder::budget_from_env("WINX_CODING_TOKEN_BUDGET", CODING_MAX_TOKENS)
720    } else {
721        crate::utils::encoder::budget_from_env("WINX_NONCODING_TOKEN_BUDGET", NONCODING_MAX_TOKENS)
722    }
723}
724
725pub(crate) fn configured_parallelism() -> usize {
726    parse_read_parallelism(crate::config::env_text("WINX_READ_PARALLELISM").as_deref())
727}
728
729fn parse_read_parallelism(value: Option<&str>) -> usize {
730    value
731        .and_then(|value| value.parse::<usize>().ok())
732        .filter(|parallelism| *parallelism > 0)
733        .unwrap_or(DEFAULT_READ_PARALLELISM)
734        .min(MAX_READ_PARALLELISM)
735}
736
737fn is_source_code_file(file_path: &str) -> bool {
738    let path = Path::new(file_path);
739    let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
740    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or_default();
741
742    matches!(file_name, "Makefile" | "Dockerfile" | "Jenkinsfile")
743        || matches!(
744            extension,
745            "py" | "pyx"
746                | "pyi"
747                | "pyw"
748                | "js"
749                | "jsx"
750                | "ts"
751                | "tsx"
752                | "mjs"
753                | "cjs"
754                | "html"
755                | "css"
756                | "scss"
757                | "sass"
758                | "less"
759                | "c"
760                | "h"
761                | "cpp"
762                | "cxx"
763                | "cc"
764                | "hpp"
765                | "java"
766                | "kt"
767                | "go"
768                | "rs"
769                | "rb"
770                | "php"
771                | "sh"
772                | "bash"
773                | "zsh"
774                | "sql"
775                | "xml"
776                | "json"
777                | "yaml"
778                | "yml"
779                | "toml"
780                | "md"
781                | "ex"
782                | "exs"
783        )
784}
785
786async fn execute_read_requests(
787    requests: &[FileReadRequest],
788    cwd: &Path,
789    workspace_root: &Path,
790    show_line_numbers: bool,
791    budget: ReadBudgetState,
792) -> ReadBatchState {
793    let mut state = ReadBatchState::new(budget);
794
795    // Files are independent, so perform blocking filesystem/tokenizer work in a
796    // bounded pool. Results are still consumed in request order, preserving the
797    // stable response and the rule that an exhausted call budget defers the rest.
798    'batches: for batch in requests.chunks(configured_parallelism()) {
799        let mut tasks = Vec::with_capacity(batch.len());
800        for request in batch.iter().cloned() {
801            let worker_path = request.clean_path.clone();
802            let worker_cwd = cwd.to_path_buf();
803            let worker_root = workspace_root.to_path_buf();
804            let start_line_num = request.start_line_num;
805            let end_line_num = request.end_line_num;
806            let caps = state.budget.worker_caps(&request);
807            let task = tokio::task::spawn_blocking(move || {
808                read_file(
809                    &worker_path,
810                    Some(select_max_tokens(&worker_path)),
811                    &worker_cwd,
812                    &worker_root,
813                    show_line_numbers,
814                    start_line_num,
815                    end_line_num,
816                    caps,
817                )
818            });
819            tasks.push((request, task));
820        }
821
822        let mut tasks = tasks.into_iter();
823        while let Some((request, task)) = tasks.next() {
824            let result = task.await.unwrap_or_else(|error| {
825                Err(WinxError::CommandExecutionError(format!(
826                    "ReadFiles worker failed for {}: {error}",
827                    request.clean_path
828                )))
829            });
830            if state.record_result(&request, result) {
831                // Running blocking jobs cannot be cancelled, but abort prevents
832                // queued work from starting. Only returned content is guarded.
833                for (_, pending) in tasks {
834                    pending.abort();
835                }
836                let deferred = &mut state.budget.report.deferred;
837                deferred.extend(requests[request.index + 1..].iter().map(FileReadRequest::spec));
838                break 'batches;
839            }
840        }
841    }
842    if !state.budget.report.deferred.is_empty() {
843        let quoted = state
844            .budget
845            .report
846            .deferred
847            .iter()
848            .map(|spec| format!("\"{spec}\""))
849            .collect::<Vec<_>>()
850            .join(", ");
851        let _ = write!(
852            state.message,
853            "\n\n[response budget reached ({} bytes); not read: {quoted}. Call ReadFiles again \
854             for exactly these.]",
855            state.budget.report.call_bytes.unwrap_or(state.budget.report.returned_bytes),
856        );
857    }
858    state
859}
860
861async fn persist_read_stats(workspace_root: &Path, stats_paths: Vec<PathBuf>) {
862    if stats_paths.is_empty() {
863        return;
864    }
865    let stats_root = workspace_root.to_path_buf();
866    let result = tokio::task::spawn_blocking(move || {
867        crate::utils::workspace_stats::record_reads(&stats_root, &stats_paths)
868    })
869    .await;
870    match result {
871        Ok(Err(error)) => debug!("failed to record read stats: {error}"),
872        Err(error) => debug!("read stats worker failed: {error}"),
873        Ok(Ok(())) => {}
874    }
875}
876
877/// Text-only library API. Direct callers always receive content: unchanged
878/// repeats are compacted only for MCP callers through
879/// [`handle_tool_call_detailed`].
880pub async fn handle_tool_call(
881    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
882    mut read_files: ReadFiles,
883) -> Result<String> {
884    read_files.force = true;
885    let outcome = handle_tool_call_detailed(bash_state_arc, read_files).await?;
886    if outcome.successful_files == 0 {
887        if let Some(error) = outcome.errors.into_iter().next() {
888            return Err(error);
889        }
890    }
891    Ok(outcome.text)
892}
893
894pub async fn handle_tool_call_detailed(
895    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
896    read_files: ReadFiles,
897) -> Result<ReadFilesOutcome> {
898    handle_tool_call_with_budget(bash_state_arc, read_files, ReadResponseBudget::from_env()).await
899}
900
901/// [`handle_tool_call_detailed`] with an explicit budget instead of the
902/// environment, for embedding and deterministic tests.
903#[expect(clippy::too_many_lines, reason = "snapshot, read and commit share one guard lifecycle")]
904pub async fn handle_tool_call_with_budget(
905    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
906    read_files: ReadFiles,
907    budget: ReadResponseBudget,
908) -> Result<ReadFilesOutcome> {
909    // Compaction needs an explicit thread: an anonymous call may resolve to
910    // whichever session was last active, which is not proof of what this
911    // conversation already holds.
912    let compact_repeats =
913        !read_files.force && !crate::types::normalize_thread_id(&read_files.thread_id).is_empty();
914    let (cwd, workspace_root, temporary_thread, unchanged_candidates) = {
915        let bash_state_guard = bash_state_arc.lock().await;
916        let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
917        let candidates = if compact_repeats {
918            bash_state.unchanged_read_candidates(budget.unchanged_ttl, Instant::now())
919        } else {
920            Vec::new()
921        };
922        (
923            bash_state.cwd.clone(),
924            bash_state.workspace_root.clone(),
925            bash_state.current_thread_id.clone(),
926            candidates,
927        )
928    };
929
930    let requests = read_files
931        .file_paths
932        .iter()
933        .enumerate()
934        .map(|(index, file_path)| FileReadRequest {
935            index,
936            requested_path: file_path.clone(),
937            clean_path: read_files.get_clean_path(index),
938            start_line_num: read_files.start_line_nums.get(index).copied().flatten(),
939            end_line_num: read_files.end_line_nums.get(index).copied().flatten(),
940        })
941        .collect::<Vec<_>>();
942    let batch = execute_read_requests(
943        &requests,
944        &cwd,
945        &workspace_root,
946        read_files.show_line_numbers(),
947        ReadBudgetState::new(budget, unchanged_candidates),
948    )
949    .await;
950    let ReadBatchState {
951        message,
952        file_ranges,
953        refreshed,
954        delivered,
955        stats_paths,
956        successful_files,
957        mut revisions,
958        errors,
959        budget,
960    } = batch;
961    let temporary_workspace = workspace_root.clone();
962    revisions = tokio::task::spawn_blocking(move || {
963        for receipt in &mut revisions {
964            if let Ok(Some((text, metadata))) = crate::utils::agent_temp::release::read_managed(
965                &temporary_workspace,
966                &temporary_thread,
967                Path::new(&receipt.path),
968            ) {
969                // The descriptor used for generation observed exactly the bytes
970                // whose revision this response exposed. A concurrent change
971                // removes eligibility rather than issuing a mismatched token.
972                if revision_from_hash(&hash_content(&text)) == receipt.revision {
973                    receipt.temporary_artifact = Some(metadata);
974                }
975            }
976        }
977        revisions
978    })
979    .await
980    .map_err(|error| {
981        WinxError::CommandExecutionError(format!("temporary read metadata worker failed: {error}"))
982    })?;
983    persist_read_stats(&workspace_root, stats_paths).await;
984
985    let mut bash_state_guard = bash_state_arc.lock().await;
986    if let Some(bash_state) = bash_state_guard.as_mut() {
987        for (path, (ranges, file_hash, total_lines)) in file_ranges {
988            bash_state.record_read_coverage(&path, ranges, file_hash, total_lines);
989        }
990        for refresh in refreshed {
991            let guarded_lines = bash_state
992                .whitelist_for_overwrite
993                .get(&refresh.path)
994                .filter(|guard| guard.file_hash == refresh.file_hash)
995                .map(|guard| guard.total_lines);
996            if let Some(total_lines) = guarded_lines {
997                bash_state.record_read_coverage(
998                    &refresh.path,
999                    [refresh.range],
1000                    refresh.file_hash,
1001                    total_lines,
1002                );
1003            }
1004        }
1005        for delivery in delivered {
1006            // Remember only content whose hash is the one now guarding edits;
1007            // a same-batch re-read of a changed file supersedes older copies.
1008            let guarded = bash_state
1009                .whitelist_for_overwrite
1010                .get(&delivery.path)
1011                .is_some_and(|guard| guard.file_hash == delivery.file_hash);
1012            if guarded {
1013                bash_state.record_read_delivery(
1014                    &delivery.path,
1015                    delivery.start_line,
1016                    delivery.end_line,
1017                    &delivery.file_hash,
1018                );
1019            }
1020        }
1021    }
1022
1023    Ok(ReadFilesOutcome {
1024        text: message,
1025        successful_files,
1026        files: revisions,
1027        errors,
1028        budget: budget.report,
1029    })
1030}
1031
1032#[cfg(test)]
1033mod tests;