1use 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
26const 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;
31const MIN_UNCHANGED_ELISION_BYTES: usize = 512;
34const OUTLINE_HINT_CHUNKS: usize = 4;
37
38type ReadCoverage = (Vec<(usize, usize)>, String, usize);
39
40#[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 content: String,
57 line_ends: Vec<usize>,
60 first_line: usize,
61 last_requested_line: usize,
63 requested_bytes: usize,
65 token_limit: Option<(usize, usize)>,
67 canonical_path: String,
68 file_hash: String,
69 total_lines: usize,
70}
71
72#[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 #[serde(skip_serializing_if = "Option::is_none")]
86 pub next_end_line: Option<usize>,
87 #[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#[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 pub omitted_bytes: usize,
114 pub unchanged_bytes: usize,
116 #[serde(skip_serializing_if = "Vec::is_empty")]
118 pub deferred: Vec<String>,
119}
120
121impl ReadBudgetReport {
122 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 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
149struct DeliveredRange {
151 path: String,
152 start_line: Option<usize>,
153 end_line: Option<usize>,
154 file_hash: String,
155}
156
157struct 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 fn chunk_bytes(&self) -> Option<usize> {
189 self.budget.file_bytes.or(self.budget.call_bytes)
190 }
191
192 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 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 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 *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
444fn 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
459fn 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 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
482fn 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#[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
514const 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
528fn 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 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 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 let Some(ids) = ids else {
680 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 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 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 '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 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
877pub 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#[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 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 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 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;