返回 CodeWhale
file.rs
根目录 / crates / tui / src / tools / file.rs
1 //! File system tools. These structs are the internal handlers behind the single
2 //! model-facing `File` tool; their `name()` values (`read_file`, `write_file`,
3 //! `edit_file`, `list_dir`, …) are dispatch keys inside `file_tool.rs` and are
4 //! NOT registered or advertised — see `crates/tui/src/tools/registry.rs:2066`.
5 //! Model-facing text must name `File` plus an `action`, never these.
6 //!
7 //! These tools provide safe file system operations within the workspace,
8 //! with path validation to prevent escaping the workspace boundary.
9
10 use super::diff_format::make_unified_diff;
11 use super::spec::{
12 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
13 lsp_diagnostics_for_paths, optional_str, optional_u64, required_str,
14 };
15 use async_trait::async_trait;
16 use serde_json::{Value, json};
17 use std::borrow::Cow;
18 use std::fs;
19 use std::path::{Path, PathBuf};
20 use std::time::Duration;
21 use tokio_util::sync::CancellationToken;
22
23 // === Cross-harness parameter aliases ===
24
25 /// Rewrite well-known parameter spellings from other coding harnesses onto the
26 /// names this tool actually implements.
27 ///
28 /// Every mainstream harness names the same three file-edit arguments
29 /// differently — `old_string`/`new_string`, `old_str`/`new_str`,
30 /// `oldText`/`newText` — and models carry whichever spelling their training
31 /// saw most. CodeWhale's canonical `search`/`replace` is the odd one out, so a
32 /// model reaching for its prior used to burn a full turn on a rejection
33 /// (#5209) and then guess again. Translating an unambiguous synonym is
34 /// strictly better than refusing it: the edit the model asked for is the edit
35 /// that happens, and the schema still advertises exactly one canonical name so
36 /// there is no new ambiguity to learn.
37 ///
38 /// This is deliberately *not* a silent-acceptance path. Only exact synonyms
39 /// are mapped, a synonym that disagrees with an explicitly supplied canonical
40 /// value is an error rather than a coin flip, and any parameter that is not a
41 /// known synonym still fails validation. The #5209 guarantee — no fabricated
42 /// "Replaced 1 occurrence" for an edit that never landed — is unchanged.
43 pub(super) struct ParamAlias {
44 /// Spelling a model might emit.
45 alias: &'static str,
46 /// Parameter this tool implements.
47 canonical: &'static str,
48 }
49
50 const fn alias(alias: &'static str, canonical: &'static str) -> ParamAlias {
51 ParamAlias { alias, canonical }
52 }
53
54 /// Path spellings shared by every file action. `path` is CodeWhale's
55 /// canonical name and the most common one in the field, but `file_path` is
56 /// widespread enough in training data to be worth accepting everywhere.
57 pub(super) const PATH_ALIASES: &[ParamAlias] =
58 &[alias("file_path", "path"), alias("filePath", "path")];
59
60 /// Edit-specific spellings. Ordered most- to least-common.
61 const EDIT_ALIASES: &[ParamAlias] = &[
62 alias("old_string", "search"),
63 alias("new_string", "replace"),
64 alias("old_str", "search"),
65 alias("new_str", "replace"),
66 alias("oldText", "search"),
67 alias("newText", "replace"),
68 alias("old_text", "search"),
69 alias("new_text", "replace"),
70 alias("replacement", "replace"),
71 ];
72
73 /// Read-window spellings. `offset`/`limit` and `line_offset`/`n_lines` both
74 /// name the same two numbers as CodeWhale's `start_line`/`max_lines` in widely
75 /// trained-on tool surfaces. A wrong guess here used to be ignored outright,
76 /// silently returning the head of the file instead of the window the model
77 /// asked for — a wrong answer shaped like a right one.
78 const READ_ALIASES: &[ParamAlias] = &[
79 alias("offset", "start_line"),
80 alias("line_offset", "start_line"),
81 alias("limit", "max_lines"),
82 alias("n_lines", "max_lines"),
83 alias("num_lines", "max_lines"),
84 ];
85
86 /// `search_name` spellings. The `File` wrapper advertises `max_results` for
87 /// both search actions, but only `search_content` implements that name; on
88 /// `search_name` the same number is spelled `limit`. Folding it here (rather
89 /// than copying it inside the wrapper) keeps one alias mechanism, so the
90 /// result-count cap a model asks for is the cap it gets whichever name it
91 /// reaches for, and a direct `file_search` call behaves the same way.
92 pub(super) const SEARCH_NAME_ALIASES: &[ParamAlias] = &[alias("max_results", "limit")];
93
94 /// `search_content` spellings, mirroring `SEARCH_NAME_ALIASES` in the other
95 /// direction: the wrapper advertises `query` and `limit` on the name-search
96 /// side, and a model that carries them across to a content search means
97 /// `pattern` and `max_results`.
98 pub(super) const SEARCH_CONTENT_ALIASES: &[ParamAlias] =
99 &[alias("query", "pattern"), alias("limit", "max_results")];
100
101 /// Apply `aliases` to `input`, in place.
102 ///
103 /// An alias is consumed only when the canonical key is absent. When both are
104 /// present and *equal* the alias is dropped as a harmless duplicate; when both
105 /// are present and disagree the call fails, because guessing which one the
106 /// model meant is exactly the fabrication this path exists to prevent.
107 pub(super) fn apply_param_aliases(
108 input: &mut Value,
109 aliases: &[ParamAlias],
110 tool_label: &str,
111 ) -> Result<(), ToolError> {
112 let Some(obj) = input.as_object_mut() else {
113 return Ok(());
114 };
115
116 for ParamAlias { alias, canonical } in aliases {
117 let Some(alias_value) = obj.remove(*alias) else {
118 continue;
119 };
120 match obj.get(*canonical) {
121 None => {
122 obj.insert((*canonical).to_string(), alias_value);
123 }
124 Some(existing) if existing == &alias_value => {}
125 Some(_) => {
126 return Err(ToolError::invalid_input(format!(
127 "{tool_label} received both `{canonical}` and its alias `{alias}` with different values, so the intended argument is ambiguous; nothing was changed. Pass only `{canonical}`."
128 )));
129 }
130 }
131 }
132
133 Ok(())
134 }
135
136 // === Per-action parameter contracts ===
137
138 /// The parameter contract for one `File` action.
139 ///
140 /// #5209 taught `edit` to refuse a parameter it does not implement instead of
141 /// dropping it and returning a success-shaped receipt. Only `edit` learned it.
142 /// Every other action kept silently discarding unknown keys, and for a reader
143 /// that is the same failure wearing a quieter costume: a misspelled
144 /// `start_line` on `read` is dropped, the head of the file comes back, and
145 /// nothing in the response says the requested window was never honored — a
146 /// wrong answer shaped like a right one.
147 ///
148 /// One table, one error shape, every action.
149 pub(super) struct ActionParams {
150 /// Action name as the model spells it on `File` (`read`, `write`, …).
151 action: &'static str,
152 /// Every parameter the action implements, canonical spellings only.
153 /// Aliases are folded onto these by [`apply_param_aliases`] before
154 /// validation runs, so they must not be listed here.
155 allowed: &'static [&'static str],
156 /// Parameters the action cannot run without.
157 required: &'static [&'static str],
158 /// `true` when exactly one of `required` is needed rather than all of
159 /// them — `patch` accepts `patch`, `replace`, or `changes`.
160 required_is_choice: bool,
161 }
162
163 const fn params(
164 action: &'static str,
165 allowed: &'static [&'static str],
166 required: &'static [&'static str],
167 ) -> ActionParams {
168 ActionParams {
169 action,
170 allowed,
171 required,
172 required_is_choice: false,
173 }
174 }
175
176 pub(super) const READ_PARAMS: ActionParams = params(
177 "read",
178 &["path", "start_line", "max_lines", "pages"],
179 &["path"],
180 );
181
182 pub(super) const WRITE_PARAMS: ActionParams =
183 params("write", &["path", "content"], &["path", "content"]);
184
185 pub(super) const EDIT_PARAMS: ActionParams = params(
186 "edit",
187 &["path", "search", "replace"],
188 &["path", "search", "replace"],
189 );
190
191 pub(super) const LIST_PARAMS: ActionParams = params("list", &["path"], &[]);
192
193 pub(super) const SEARCH_NAME_PARAMS: ActionParams = params(
194 "search_name",
195 &["query", "path", "limit", "extensions", "exclude"],
196 &["query"],
197 );
198
199 pub(super) const SEARCH_CONTENT_PARAMS: ActionParams = params(
200 "search_content",
201 &[
202 "pattern",
203 "path",
204 "include",
205 "exclude",
206 "context_lines",
207 "case_insensitive",
208 "max_results",
209 ],
210 &["pattern"],
211 );
212
213 pub(super) const PATCH_PARAMS: ActionParams = ActionParams {
214 action: "patch",
215 allowed: &[
216 "path",
217 "patch",
218 "replace",
219 "changes",
220 "fuzz",
221 "create_if_missing",
222 ],
223 required: &["patch", "replace", "changes"],
224 required_is_choice: true,
225 };
226
227 /// Render `names` as a backticked, comma-separated English list.
228 fn quoted_list(names: &[&str], conjunction: &str) -> String {
229 let quoted: Vec<String> = names.iter().map(|name| format!("`{name}`")).collect();
230 match quoted.as_slice() {
231 [] => "none".to_string(),
232 [only] => only.clone(),
233 [first, second] => format!("{first} {conjunction} {second}"),
234 [head @ .., last] => format!("{}, {conjunction} {last}", head.join(", ")),
235 }
236 }
237
238 impl ActionParams {
239 /// Reject parameter names this action does not implement.
240 ///
241 /// Must run *after* [`apply_param_aliases`], exactly as the `edit` path
242 /// does. The alias lane's reasoning stands: translating an unambiguous
243 /// synonym is better than refusing it, so by the time this runs every
244 /// spelling with a known meaning has already been folded onto its
245 /// canonical name. What is left is a name with no known meaning, where
246 /// continuing would mean guessing which argument was intended — so it
247 /// hard-errors rather than dropping the argument and reporting success.
248 pub(super) fn reject_unknown(&self, input: &Value) -> Result<(), ToolError> {
249 let action = self.action;
250 let required = if self.required_is_choice {
251 format!("one of {}", quoted_list(self.required, "or"))
252 } else {
253 quoted_list(self.required, "and")
254 };
255
256 let Some(obj) = input.as_object() else {
257 return Err(ToolError::invalid_input(format!(
258 "File {action} input must be an object. Allowed parameters are {}. Required: {required}. The {action} was not performed.",
259 quoted_list(self.allowed, "and"),
260 )));
261 };
262
263 let unexpected: Vec<&str> = obj
264 .keys()
265 .map(String::as_str)
266 .filter(|key| !self.allowed.contains(key))
267 .collect();
268 if !unexpected.is_empty() {
269 return Err(ToolError::invalid_input(format!(
270 "unexpected File {action} parameter(s): {}. Allowed parameters are {}. Required: {required}. The {action} was not performed.",
271 unexpected.join(", "),
272 quoted_list(self.allowed, "and"),
273 )));
274 }
275
276 Ok(())
277 }
278
279 /// A required parameter that is not also allowed would make the refusal
280 /// self-contradicting: it would name an argument the same check rejects.
281 #[cfg(test)]
282 pub(super) fn assert_required_is_allowed(&self) {
283 for name in self.required {
284 assert!(
285 self.allowed.contains(name),
286 "File {} requires `{name}` but does not allow it",
287 self.action
288 );
289 }
290 }
291 }
292
293 // === ReadFileTool ===
294
295 fn canonical_path_for_credential_guard(path: &Path) -> PathBuf {
296 fs::canonicalize(path).unwrap_or_else(|_| {
297 if path.is_absolute() {
298 path.to_path_buf()
299 } else {
300 std::env::current_dir()
301 .unwrap_or_else(|_| PathBuf::from("."))
302 .join(path)
303 }
304 })
305 }
306
307 fn config_backup_path_for_credential_guard(config_path: &Path) -> PathBuf {
308 let mut file_name = config_path
309 .file_name()
310 .map(std::ffi::OsString::from)
311 .unwrap_or_else(|| std::ffi::OsString::from(codewhale_config::CONFIG_FILE_NAME));
312 file_name.push(".bak");
313 config_path
314 .parent()
315 .unwrap_or_else(|| Path::new("."))
316 .join(file_name)
317 }
318
319 fn is_config_or_backup(candidate: &Path, config_path: &Path) -> bool {
320 let config_path = canonical_path_for_credential_guard(config_path);
321 let backup_path =
322 canonical_path_for_credential_guard(&config_backup_path_for_credential_guard(&config_path));
323 candidate == config_path || candidate == backup_path
324 }
325
326 /// Return whether `read_file` must refuse a CodeWhale-owned credential file.
327 ///
328 /// This is deliberately scoped to the active config, the two conventional
329 /// config locations (including one-time backups), and CodeWhale's file-backed
330 /// secret-store directories. Other dotfiles remain readable. Model-bound
331 /// redaction is still required because shell tools can read these files and
332 /// arbitrary commands can print credentials without reading a file at all.
333 fn is_codewhale_credential_path(path: &Path) -> bool {
334 let candidate = canonical_path_for_credential_guard(path);
335
336 if let Ok(active_config) = codewhale_config::resolve_config_path(None)
337 && is_config_or_backup(&candidate, &active_config)
338 {
339 return true;
340 }
341
342 let roots = [
343 codewhale_config::codewhale_home(),
344 codewhale_config::legacy_deepseek_home(),
345 ];
346 for root in roots.into_iter().flatten() {
347 if is_config_or_backup(&candidate, &root.join(codewhale_config::CONFIG_FILE_NAME)) {
348 return true;
349 }
350
351 let secrets_dir = canonical_path_for_credential_guard(&root.join("secrets"));
352 if candidate.starts_with(secrets_dir) {
353 return true;
354 }
355 }
356
357 false
358 }
359
360 /// Tool for reading UTF-8 files from the workspace.
361 pub struct ReadFileTool;
362
363 #[async_trait]
364 impl ToolSpec for ReadFileTool {
365 fn name(&self) -> &'static str {
366 "read_file"
367 }
368
369 fn model_visible(&self) -> bool {
370 false
371 }
372
373 fn description(&self) -> &'static str {
374 "Read a UTF-8 file from the workspace. Use this instead of `cat`, `head`, `tail`, or `sed -n '..p'` in `Bash` — it's faster, sandbox-aware, and skips the approval prompt. Plain text is returned as-is and records the file snapshot required before `edit` will make a narrow in-place edit. CodeWhale config files and file-backed credential stores cannot be read with this tool; use `codewhale config list` or `codewhale auth status` for safe inspection. PDFs are text-extracted when the optional `pdftotext` executable (Poppler) is installed. Image screenshots are OCR-extracted when local OCR is available. Cannot read other non-PDF binaries.\n\nFor large files, use `start_line` and `max_lines` to read in chunks. By default, returns up to 500 lines or 16KB, whichever comes first. If `truncated=\"true\"` and `next_start_line` is present, continue reading from there; a byte-limited window instead shows head + tail with a `[CONTENT TRUNCATED]` marker and its note says how to narrow the range. For PDFs, use `pages` instead — `start_line`/`max_lines` only apply to text files."
375 }
376
377 fn input_schema(&self) -> Value {
378 json!({
379 "type": "object",
380 "properties": {
381 "path": {
382 "type": "string",
383 "description": "Path to the file (relative to workspace or absolute). Alias: `file_path`"
384 },
385 "start_line": {
386 "type": "integer",
387 "description": "Starting line (1-based, default 1). Aliases: `offset`, `line_offset`"
388 },
389 "max_lines": {
390 "type": "integer",
391 "description": "Maximum lines to return (default 500, max 500; a 16KB byte budget applies regardless). Aliases: `limit`, `n_lines`"
392 },
393 "pages": {
394 "type": "string",
395 "description": "PDF only: page range to extract, e.g. \"1-5\" or \"10\". Ignored for non-PDF files."
396 }
397 },
398 "required": ["path"]
399 })
400 }
401
402 fn capabilities(&self) -> Vec<ToolCapability> {
403 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
404 }
405
406 fn supports_parallel(&self) -> bool {
407 true
408 }
409
410 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
411 let mut input = input;
412 apply_param_aliases(&mut input, PATH_ALIASES, "File read")?;
413 apply_param_aliases(&mut input, READ_ALIASES, "File read")?;
414 READ_PARAMS.reject_unknown(&input)?;
415
416 let path_str = required_str(&input, "path")?;
417 let file_path = context.resolve_path(path_str)?;
418 if is_codewhale_credential_path(&file_path) {
419 return Err(ToolError::permission_denied(
420 "File `read` cannot expose CodeWhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection",
421 ));
422 }
423 let pages = optional_str(&input, "pages")?;
424
425 if let Some(result) = read_pdf_if_detected(
426 &file_path,
427 pages,
428 super::pdf::PdfTextCommand::system(context.cancel_token.as_ref()),
429 )
430 .await?
431 {
432 return Ok(result);
433 }
434 if is_image_for_ocr(&file_path) {
435 return read_image_via_ocr(&file_path, path_str);
436 }
437
438 // Open before parameter parsing so a missing file keeps the
439 // historical "Failed to read …" error shape regardless of the other
440 // arguments.
441 let file = fs::File::open(&file_path).map_err(|e| {
442 ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
443 })?;
444 let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX);
445
446 let explicit_range = input
447 .get("start_line")
448 .or_else(|| input.get("max_lines"))
449 .is_some();
450
451 // Small-file fast path. Only applies when the caller didn't pass an
452 // explicit range — otherwise an explicit `start_line = 5` on a
453 // tiny file would silently ignore the request.
454 if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 {
455 drop(file);
456 let contents = fs::read_to_string(&file_path).map_err(|e| {
457 ToolError::execution_failed(format!(
458 "Failed to read {}: {}",
459 file_path.display(),
460 e
461 ))
462 })?;
463 context.note_file_read(&file_path);
464
465 let total_lines = contents.lines().count();
466 if total_lines <= SMALL_FILE_LINES {
467 return Ok(ToolResult::success(contents).with_metadata(json!({
468 "evidence_routing": "inline"
469 })));
470 }
471
472 // Small in bytes but too many lines: render the default window
473 // straight from the in-memory contents.
474 let window: Vec<String> = contents
475 .lines()
476 .take(DEFAULT_READ_LINES)
477 .map(str::to_string)
478 .collect();
479 return Ok(render_line_window(
480 path_str,
481 &window,
482 total_lines,
483 1,
484 DEFAULT_READ_LINES,
485 ));
486 }
487
488 // Strict types (2026-08-04 review): a `start_line:"1200"` string or a
489 // negative/float value used to silently fall back to the defaults —
490 // returning the head of the file instead of the window the model
491 // asked for, the exact wrong-answer-shaped-like-a-right-one this
492 // action's alias/unknown-parameter hardening exists to prevent.
493 let start_line = match optional_u64(&input, "start_line", 1)? {
494 0 => {
495 return Err(ToolError::invalid_input(
496 "start_line must be 1-based and greater than 0".to_string(),
497 ));
498 }
499 v => usize::try_from(v).map_err(|_| {
500 ToolError::invalid_input(
501 "start_line exceeds platform addressable range".to_string(),
502 )
503 })?,
504 };
505
506 let max_lines = match optional_u64(&input, "max_lines", DEFAULT_READ_LINES as u64)? {
507 0 => {
508 return Err(ToolError::invalid_input(
509 "max_lines must be greater than 0".to_string(),
510 ));
511 }
512 v => {
513 let converted = usize::try_from(v).map_err(|_| {
514 ToolError::invalid_input(
515 "max_lines exceeds platform addressable range".to_string(),
516 )
517 })?;
518 std::cmp::min(converted, HARD_MAX_READ_LINES)
519 }
520 };
521
522 // Bounded read for ranged/large files: skip and take lines through a
523 // BufReader instead of materializing the whole file. The stream still
524 // runs to EOF so the total line count and whole-file UTF-8 validation
525 // match the historical read_to_string behavior.
526 let (window, total_lines) =
527 read_window_streaming(file, start_line, max_lines).map_err(|e| {
528 ToolError::execution_failed(format!(
529 "Failed to read {}: {}",
530 file_path.display(),
531 e
532 ))
533 })?;
534 context.note_file_read(&file_path);
535
536 // `start_line > total_lines` is not an error — it lets the model
537 // page past the end without raising. Returns an empty-content
538 // sentinel so subsequent reads can stop.
539 if start_line > total_lines {
540 let output = format!(
541 "<file path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"none\" truncated=\"false\">\n\
542 \n\
543 [NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n\
544 </file>"
545 );
546 return Ok(ToolResult::success(output).with_metadata(json!({
547 "evidence_routing": "inline"
548 })));
549 }
550
551 Ok(render_line_window(
552 path_str,
553 &window,
554 total_lines,
555 start_line,
556 max_lines,
557 ))
558 }
559 }
560
561 // Bounded output for large files. The small-file fast path keeps the
562 // historical "return contents unchanged" behavior so existing flows
563 // (small configs, single source files, etc.) don't suddenly start
564 // seeing wrapped output. Once a file is large or the caller asks
565 // for an explicit range, we switch to a numbered, line-tagged
566 // window with continuation hints so the model can page through
567 // without re-loading the entire file on every turn. Harvested
568 // from PR #1451 by @Oliver-ZPLiu, closes part of #1450.
569 // One bound, not two competing ones. The real cost of a read is BYTES of
570 // context, and `MAX_VISIBLE_BYTES` already enforces that. A separate 200-line
571 // default fired long before the byte budget on any prose file — a 229-line,
572 // 12 KB document truncated at line 200 with a third of the budget unspent,
573 // costing a second round trip to fetch 29 lines. The line cap now only guards
574 // pathologically short lines, where 500 lines is still a small read.
575 const DEFAULT_READ_LINES: usize = HARD_MAX_READ_LINES;
576 const HARD_MAX_READ_LINES: usize = 500;
577 const MAX_VISIBLE_BYTES: usize = 16 * 1024;
578 const SMALL_FILE_LINES: usize = HARD_MAX_READ_LINES;
579 const SMALL_FILE_BYTES: usize = 16 * 1024;
580
581 /// Stream a line window out of `file`: skip `start_line - 1` lines, collect
582 /// up to `max_lines`, then keep counting (and validating UTF-8) to EOF.
583 /// Returns the collected window plus the total line count. Only the window
584 /// is ever held in memory.
585 fn read_window_streaming(
586 file: fs::File,
587 start_line: usize,
588 max_lines: usize,
589 ) -> std::io::Result<(Vec<String>, usize)> {
590 use std::io::BufRead;
591
592 let mut reader = std::io::BufReader::new(file);
593 let mut raw: Vec<u8> = Vec::new();
594 let mut window: Vec<String> = Vec::new();
595 let mut total_lines = 0usize;
596 let start_idx = start_line - 1;
597
598 loop {
599 raw.clear();
600 let n = reader.read_until(b'\n', &mut raw)?;
601 if n == 0 {
602 break;
603 }
604 // Mirror `str::lines`: strip the trailing '\n', and a '\r' only when
605 // it directly precedes that '\n'.
606 let mut end = raw.len();
607 if raw[..end].ends_with(b"\n") {
608 end -= 1;
609 if raw[..end].ends_with(b"\r") {
610 end -= 1;
611 }
612 }
613 // Validate every line so invalid UTF-8 anywhere in the file fails
614 // exactly like the previous whole-file read_to_string did.
615 let line = std::str::from_utf8(&raw[..end]).map_err(|_| {
616 std::io::Error::new(
617 std::io::ErrorKind::InvalidData,
618 "stream did not contain valid UTF-8",
619 )
620 })?;
621 if total_lines >= start_idx && window.len() < max_lines {
622 window.push(line.to_string());
623 }
624 total_lines += 1;
625 }
626
627 Ok((window, total_lines))
628 }
629
630 /// Marker placed between the retained head and tail when a read window is
631 /// truncated by the byte budget. Mirrors qwen-code's truncation style so the
632 /// model sees both ends of the range.
633 const BYTE_TRUNCATION_SEPARATOR: &str = "\n\n---\n... [CONTENT TRUNCATED] ...\n---\n\n";
634
635 /// Split `content` into a head of at most `head_budget` bytes and a tail that
636 /// fills the remainder of `total_budget` (separator accounted for). Never
637 /// overlaps and never splits mid-codepoint. Style matches qwen-code:
638 /// `head_budget = total_budget / 5`.
639 fn head_tail_for_budget(content: &str, total_budget: usize) -> (String, String) {
640 let head_budget = (total_budget / 5).max(1);
641 let head_end = (0..=head_budget.min(content.len()))
642 .rev()
643 .find(|&i| content.is_char_boundary(i))
644 .unwrap_or(0);
645 let sep_len = BYTE_TRUNCATION_SEPARATOR.len();
646 let tail_budget = total_budget
647 .saturating_sub(head_end)
648 .saturating_sub(sep_len)
649 .max(1);
650 let tail_floor = content.len().saturating_sub(tail_budget).max(head_end);
651 let tail_start = (tail_floor..=content.len())
652 .find(|&i| content.is_char_boundary(i))
653 .unwrap_or(content.len());
654 (
655 content[..head_end].to_string(),
656 content[tail_start..].to_string(),
657 )
658 }
659
660 /// Render a collected line window into the `<file …>` wrapper used for
661 /// ranged/large reads. `window` must hold the lines for
662 /// `start_line..start_line + max_lines` (clamped to EOF).
663 fn render_line_window(
664 path_str: &str,
665 window: &[String],
666 total_lines: usize,
667 start_line: usize,
668 max_lines: usize,
669 ) -> ToolResult {
670 let zero_based_start = start_line - 1;
671 let zero_based_end = std::cmp::min(zero_based_start + max_lines, total_lines);
672 let shown_first = start_line;
673 let shown_last = zero_based_end; // 1-based inclusive line number of the last shown line
674
675 let mut numbered = String::new();
676 for (offset, line) in window.iter().enumerate() {
677 let line_no = start_line + offset;
678 numbered.push_str(&format!("{line_no:>6}│ {line}\n"));
679 }
680
681 // UTF-8-safe byte truncation of the rendered range. Qwen-style: keep a
682 // short head (budget/5) plus the matching tail so the model sees both
683 // ends of a long range. The full file already lives at `path_str` — the
684 // recovery note names that absolute/workspace path for a re-read.
685 let truncated_by_bytes = numbered.len() > MAX_VISIBLE_BYTES;
686 let shown_content = if truncated_by_bytes {
687 let (head, tail) = head_tail_for_budget(&numbered, MAX_VISIBLE_BYTES);
688 format!("{head}{BYTE_TRUNCATION_SEPARATOR}{tail}")
689 } else {
690 numbered
691 };
692
693 let truncated_by_lines = zero_based_end < total_lines;
694 let truncated = truncated_by_lines || truncated_by_bytes;
695 let next_start = zero_based_end + 1;
696
697 let mut attrs = format!(
698 "path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"{shown_first}-{shown_last}\" truncated=\"{truncated}\""
699 );
700 if truncated_by_lines {
701 attrs.push_str(&format!(" next_start_line=\"{next_start}\""));
702 }
703
704 let mut output = format!("<file {attrs}>\n{shown_content}");
705 if truncated_by_lines {
706 output.push_str(&format!(
707 "\n[TRUNCATED] Showing lines {shown_first}-{shown_last} of {total_lines}. To continue, call File with action=\"read\" path=\"{path_str}\" start_line={next_start} max_lines={max_lines}\n"
708 ));
709 }
710 if truncated_by_bytes {
711 if shown_first == shown_last {
712 // One line alone exceeds the byte budget: no start_line/max_lines
713 // combination can ever reveal the elided middle, so the note must
714 // not pretend otherwise — name the escape hatch that works.
715 output.push_str(&format!(
716 "\n[TRUNCATED] Line {shown_first} alone exceeds 16KB; showing its head + tail. No `start_line`/`max_lines` window can reveal the middle of one line — use File action=\"search_content\" to find what you need inside it, or Bash (e.g. `cut -c` on that line) to slice by column.\n"
717 ));
718 } else {
719 let narrower = (shown_last - shown_first).div_ceil(2).max(1);
720 output.push_str(&format!(
721 "\n[TRUNCATED] The selected range exceeded 16KB; showing head + tail of lines {shown_first}-{shown_last}. Re-read narrower windows to see the middle, e.g. start_line={shown_first} max_lines={narrower}, then advance start_line.\n"
722 ));
723 }
724 }
725 output.push_str("</file>");
726
727 // The file tool self-bounds at 16 KiB and carries its own continuation
728 // contract (`next_start_line`), so the large-output spillover envelope
729 // must never re-wrap a read result with a second, weaker truncation.
730 ToolResult::success(output).with_metadata(json!({
731 "evidence_routing": "inline"
732 }))
733 }
734
735 fn read_image_via_ocr(path: &Path, requested_path: &str) -> Result<ToolResult, ToolError> {
736 let text = crate::tools::image_ocr::ocr_image_path(path)?;
737 Ok(ToolResult::success(format!(
738 "<image_ocr path=\"{requested_path}\">\n{text}\n</image_ocr>"
739 )))
740 }
741
742 /// Detect an existing PDF by extension or by sniffing `%PDF` magic bytes.
743 fn is_pdf(path: &Path) -> Result<bool, ToolError> {
744 let extension_matches = path
745 .extension()
746 .and_then(|e| e.to_str())
747 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"));
748 let mut file = fs::File::open(path).map_err(|error| {
749 ToolError::execution_failed(format!("Failed to read {}: {error}", path.display()))
750 })?;
751 if extension_matches {
752 return Ok(true);
753 }
754 let mut buf = [0u8; 4];
755 use std::io::Read;
756 Ok(file.read_exact(&mut buf).is_ok() && &buf == b"%PDF")
757 }
758
759 fn is_image_for_ocr(path: &Path) -> bool {
760 path.extension()
761 .and_then(|e| e.to_str())
762 .is_some_and(|ext| {
763 matches!(
764 ext.to_ascii_lowercase().as_str(),
765 "png" | "jpg" | "jpeg" | "tif" | "tiff" | "bmp"
766 )
767 })
768 }
769
770 fn parse_pages_arg(spec: &str) -> Option<(u32, u32)> {
771 let trimmed = spec.trim();
772 if trimmed.is_empty() {
773 return None;
774 }
775 if let Some((a, b)) = trimmed.split_once('-') {
776 let start: u32 = a.trim().parse().ok()?;
777 let end: u32 = b.trim().parse().ok()?;
778 if start == 0 || end < start {
779 return None;
780 }
781 Some((start, end))
782 } else {
783 let n: u32 = trimmed.parse().ok()?;
784 if n == 0 {
785 return None;
786 }
787 Some((n, n))
788 }
789 }
790
791 /// Clean PDF-extracted text for TUI display: collapse consecutive blank
792 /// lines (more than 1 becomes 1), replace NUL bytes with U+FFFD, replace
793 /// non-breaking spaces with regular spaces, and trim trailing whitespace
794 /// on each line. Produces output that won't clutter the transcript with
795 /// vertical gaps or invisible control characters.
796 fn clean_pdf_text(raw: &str) -> String {
797 let mut out = String::with_capacity(raw.len());
798 let mut blank_run = 0usize;
799 let mut any_content = false;
800 for line in raw.lines() {
801 let trimmed = line.trim_end();
802 if trimmed.is_empty() {
803 blank_run = blank_run.saturating_add(1);
804 if blank_run <= 1 {
805 out.push('\n');
806 }
807 } else {
808 blank_run = 0;
809 any_content = true;
810 // Push cleaned characters directly — avoids a per-line
811 // temporary String allocation.
812 for c in trimmed.chars() {
813 match c {
814 '\0' => out.push('\u{FFFD}'),
815 '\u{A0}' => out.push(' '),
816 other => out.push(other),
817 }
818 }
819 out.push('\n');
820 }
821 }
822 // Trim leading blank lines only — don't use str::trim() which
823 // would also strip intentional indentation (e.g. centred titles).
824 if any_content {
825 let start = out.find(|c: char| c != '\n').unwrap_or(0);
826 // Walk back from end to find the last non-newline character.
827 let end = out.rfind(|c: char| c != '\n').map_or(out.len(), |i| {
828 i + out[i..].chars().next().map_or(1, |c| c.len_utf8())
829 });
830 out[start..end].to_string()
831 } else {
832 String::new()
833 }
834 }
835
836 async fn read_pdf_if_detected(
837 path: &Path,
838 pages: Option<&str>,
839 command: super::pdf::PdfTextCommand<'_>,
840 ) -> Result<Option<ToolResult>, ToolError> {
841 if !is_pdf(path)? {
842 return Ok(None);
843 }
844 // Validate the `pages` spec once, up front, so both extractor paths
845 // surface the same error shape on bad input.
846 let page_range = match pages {
847 Some(spec) => match parse_pages_arg(spec) {
848 Some((start, end)) => Some((start, end)),
849 None => {
850 return Err(ToolError::invalid_input(format!(
851 "invalid `pages` value `{spec}` (expected `N` or `N-M`, e.g. `1-5`)"
852 )));
853 }
854 },
855 None => None,
856 };
857
858 read_pdf_with_command(path, page_range, command)
859 .await
860 .map(Some)
861 }
862
863 async fn read_pdf_with_command(
864 path: &Path,
865 page_range: Option<(u32, u32)>,
866 command: super::pdf::PdfTextCommand<'_>,
867 ) -> Result<ToolResult, ToolError> {
868 let text = super::pdf::extract_path(path, page_range, command)
869 .await
870 .map_err(super::pdf::into_tool_error)?;
871 Ok(ToolResult::success(clean_pdf_text(&text)))
872 }
873
874 // === WriteFileTool ===
875
876 /// Tool for writing UTF-8 files to the workspace.
877 pub struct WriteFileTool;
878
879 #[async_trait]
880 impl ToolSpec for WriteFileTool {
881 fn name(&self) -> &'static str {
882 "write_file"
883 }
884
885 fn model_visible(&self) -> bool {
886 false
887 }
888
889 fn description(&self) -> &'static str {
890 "Write content to a UTF-8 file in the workspace. Use this instead of heredocs (`cat <<EOF > file`) or `echo > file` in `Bash` — diffs render inline and approval is handled cleanly. Creates or overwrites; parent directories are auto-created."
891 }
892
893 fn input_schema(&self) -> Value {
894 json!({
895 "type": "object",
896 "properties": {
897 "path": {
898 "type": "string",
899 "description": "Path to the file. Alias: `file_path`"
900 },
901 "content": {
902 "type": "string",
903 "description": "Content to write"
904 }
905 },
906 "required": ["path", "content"]
907 })
908 }
909
910 fn capabilities(&self) -> Vec<ToolCapability> {
911 vec![
912 ToolCapability::WritesFiles,
913 ToolCapability::Sandboxable,
914 ToolCapability::RequiresApproval,
915 ]
916 }
917
918 fn approval_requirement(&self) -> ApprovalRequirement {
919 ApprovalRequirement::Suggest
920 }
921
922 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
923 let mut input = input;
924 apply_param_aliases(&mut input, PATH_ALIASES, "File write")?;
925 WRITE_PARAMS.reject_unknown(&input)?;
926
927 let path_str = required_str(&input, "path")?;
928 let file_content = required_str(&input, "content")?;
929
930 let file_path = context.resolve_path(path_str)?;
931
932 // Snapshot the existing contents (if any) before we overwrite — used
933 // to render an inline diff in the tool result.
934 let existed_before = file_path.exists();
935 let prior_contents = if existed_before {
936 fs::read_to_string(&file_path).unwrap_or_default()
937 } else {
938 String::new()
939 };
940
941 // Create parent directories if needed
942 if let Some(parent) = file_path.parent() {
943 fs::create_dir_all(parent).map_err(|e| {
944 ToolError::execution_failed(format!(
945 "Failed to create directory {}: {}",
946 parent.display(),
947 e
948 ))
949 })?;
950 }
951
952 crate::utils::write_atomic_workspace(&file_path, file_content.as_bytes()).map_err(|e| {
953 ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
954 })?;
955 context.note_file_read(&file_path);
956
957 let display = file_path.display().to_string();
958 let diff = make_unified_diff(&display, &prior_contents, file_content);
959 let summary = if existed_before {
960 format!("Wrote {} bytes to {}", file_content.len(), display)
961 } else {
962 format!("Created {} ({} bytes)", display, file_content.len())
963 };
964 let body = if diff.is_empty() {
965 format!("{summary}\n(no changes)")
966 } else {
967 format!("{diff}\n{summary}")
968 };
969
970 // Append LSP diagnostics for the written file when enabled (#428).
971 let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
972 let full_body = if diag_block.is_empty() {
973 body
974 } else {
975 format!("{body}\n{diag_block}")
976 };
977
978 let outcome = if existed_before { "updated" } else { "created" };
979 // Keep the execution-owned receipt workspace-relative even though the
980 // legacy model-facing output above retains its resolved-path wording.
981 let receipt_diff = make_unified_diff(path_str, &prior_contents, file_content);
982 Ok(ToolResult::success(full_body).with_metadata(json!({
983 "event": "file.mutation",
984 "mutation": {
985 "diff": receipt_diff,
986 "files": [{ "path": path_str, "outcome": outcome }],
987 "renames": []
988 }
989 })))
990 }
991 }
992
993 // === EditFileTool ===
994
995 /// Tool for search/replace editing of files.
996 pub struct EditFileTool;
997
998 #[async_trait]
999 impl ToolSpec for EditFileTool {
1000 fn name(&self) -> &'static str {
1001 "edit_file"
1002 }
1003
1004 fn model_visible(&self) -> bool {
1005 false
1006 }
1007
1008 fn description(&self) -> &'static str {
1009 "Replace text in a single file via exact search/replace after the file has been read with File `read` in this session. Use this instead of `sed -i` in `Bash` for one unambiguous in-place edit. `search` must match exactly one location by default; when no exact match is found the tool retries with leading-whitespace-tolerant fuzzy matching automatically. Returns a compact unified diff, not the full file. For structural, multi-block, or cross-file changes, use File `patch` or `write` instead."
1010 }
1011
1012 fn input_schema(&self) -> Value {
1013 json!({
1014 "type": "object",
1015 "properties": {
1016 "path": {
1017 "type": "string",
1018 "description": "Path to the file. Alias: `file_path`"
1019 },
1020 "search": {
1021 "type": "string",
1022 "description": "Exact text to search for, including whitespace, indentation, and newlines. Aliases: `old_string`, `old_str`, `oldText`"
1023 },
1024 "replace": {
1025 "type": "string",
1026 "description": "Text to replace with. Aliases: `new_string`, `new_str`, `newText`"
1027 }
1028 },
1029 "required": ["path", "search", "replace"]
1030 })
1031 }
1032
1033 fn capabilities(&self) -> Vec<ToolCapability> {
1034 vec![
1035 ToolCapability::WritesFiles,
1036 ToolCapability::Sandboxable,
1037 ToolCapability::RequiresApproval,
1038 ]
1039 }
1040
1041 fn approval_requirement(&self) -> ApprovalRequirement {
1042 ApprovalRequirement::Suggest
1043 }
1044
1045 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1046 // Translate known cross-harness spellings (`old_string`/`new_string`,
1047 // `old_str`/`new_str`, …) onto `search`/`replace` first, then reject
1048 // whatever is left that we do not implement. #5209 required that a
1049 // mis-named edit never produce a success-shaped receipt for a file
1050 // that did not change; performing the edit the model unambiguously
1051 // asked for satisfies that more directly than refusing it did.
1052 let mut input = input;
1053 apply_param_aliases(&mut input, PATH_ALIASES, "File edit")?;
1054 apply_param_aliases(&mut input, EDIT_ALIASES, "File edit")?;
1055 EDIT_PARAMS.reject_unknown(&input)?;
1056
1057 let path_str = required_str(&input, "path")?;
1058 let search = required_str(&input, "search")?;
1059 let replace = required_str(&input, "replace")?;
1060
1061 if search == replace {
1062 // #5003 — long-text edits repeatedly failed here because the model
1063 // generated a `replace` identical to `search`. A bare "no change"
1064 // message gave no hint of the root cause, so the model retried the
1065 // same broken call. Spell out the failure and the recovery path.
1066 let char_count = search.chars().count();
1067 let line_count = search.lines().count();
1068 return Err(ToolError::invalid_input(format!(
1069 "search and replace are identical ({char_count} chars, {line_count} lines), so no change is possible. This usually means `replace` was copied verbatim from `search` instead of carrying the intended edits. Recovery: re-read the file with File action=\"read\", then retry with a `replace` that is genuinely different from `search`; for large multi-line rewrites prefer apply_patch with a unified diff."
1070 )));
1071 }
1072 if search.is_empty() {
1073 return Err(ToolError::invalid_input("search must not be empty"));
1074 }
1075 if let Some(reason) = edit_payload_looks_corrupted(search, replace) {
1076 return Err(ToolError::invalid_input(format!(
1077 "edit_file refused corrupted payload: {reason}. Recovery: re-read the file and retry with a complete replace (or use apply_patch for brace-heavy multi-line edits)."
1078 )));
1079 }
1080
1081 let file_path = context.resolve_path(path_str)?;
1082 context.require_fresh_file_read(&file_path, path_str)?;
1083
1084 let contents = fs::read_to_string(&file_path).map_err(|e| {
1085 ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
1086 })?;
1087
1088 // Models provide LF newlines even when the file on disk uses CRLF.
1089 // Match in a newline-normalized view, while retaining the sparse
1090 // positions where CR bytes were removed so only the original span is
1091 // replaced and the rest of the file stays byte-for-byte untouched.
1092 let (normalized_contents, crlf_positions) = normalize_crlf_with_positions(&contents);
1093 let normalized_search = normalize_crlf(search);
1094 let mut exact_ranges = normalized_contents
1095 .match_indices(normalized_search.as_ref())
1096 .map(|(start, matched)| (start, start + matched.len()));
1097 let first_exact_match = exact_ranges
1098 .next()
1099 .map(|range| map_normalized_range(range, crlf_positions.as_deref()));
1100 let exact_count = usize::from(first_exact_match.is_some()) + exact_ranges.count();
1101
1102 let ((match_start, match_end), fuzz_kind) = if exact_count == 0 {
1103 // First fallback: tolerate indentation differences.
1104 let indent_matches = map_normalized_ranges(
1105 leading_whitespace_fuzzy_matches(
1106 normalized_contents.as_ref(),
1107 normalized_search.as_ref(),
1108 ),
1109 crlf_positions.as_deref(),
1110 );
1111 match indent_matches.as_slice() {
1112 [(start, end)] => ((*start, *end), Some("indentation")),
1113 [] => {
1114 // Second fallback: tolerate typographic-punctuation
1115 // drift (smart quotes, em-dashes, NBSP). Picks up the
1116 // copy-paste failure mode where a browser/chat client
1117 // silently substituted Unicode punctuation in for the
1118 // ASCII the file actually contains.
1119 let punct_matches = map_normalized_ranges(
1120 punctuation_normalized_matches(
1121 normalized_contents.as_ref(),
1122 normalized_search.as_ref(),
1123 ),
1124 crlf_positions.as_deref(),
1125 );
1126 match punct_matches.as_slice() {
1127 [] => {
1128 // #5003 — the model could not tell why its search
1129 // missed; show the first lines of the search text
1130 // so it can compare against the file's contents.
1131 return Err(ToolError::execution_failed(format!(
1132 "Search string not found in {}. The search text starts with:\n{}\nRecovery: call File with action=\"read\" path=\"{path_str}\" to inspect the current contents, then retry with a search string copied from the file.",
1133 file_path.display(),
1134 preview_search_for_error(search),
1135 )));
1136 }
1137 [(start, end)] => ((*start, *end), Some("punctuation")),
1138 _ => {
1139 return Err(ToolError::execution_failed(format!(
1140 "File `edit` search is non-unique after punctuation normalization: matched {} locations in {}. Recovery: call File with action=\"read\" path=\"{path_str}\" and retry with surrounding lines that make the search unique.",
1141 punct_matches.len(),
1142 file_path.display()
1143 )));
1144 }
1145 }
1146 }
1147 _ => {
1148 return Err(ToolError::execution_failed(format!(
1149 "File `edit` search is non-unique after indentation normalization: matched {} locations in {}. Recovery: call File with action=\"read\" path=\"{path_str}\" and retry with surrounding lines that make the search unique.",
1150 indent_matches.len(),
1151 file_path.display()
1152 )));
1153 }
1154 }
1155 } else if exact_count > 1 {
1156 return Err(ToolError::execution_failed(format!(
1157 "File `edit` search is non-unique: matched {} locations in {}. \
1158 Recovery: call File with action=\"read\" path=\"{path_str}\" and retry with surrounding lines that make the search unique.",
1159 exact_count,
1160 file_path.display()
1161 )));
1162 } else {
1163 let Some((start, end)) = first_exact_match else {
1164 return Err(ToolError::execution_failed(
1165 "edit_file internal range accounting failed — refusing write",
1166 ));
1167 };
1168 let fuzz_kind = (&contents[start..end] != search).then_some("line endings");
1169 ((start, end), fuzz_kind)
1170 };
1171
1172 let effective_replace =
1173 normalize_replacement_line_endings(replace, crlf_positions.is_some());
1174 let mut updated = contents.clone();
1175 updated.replace_range(match_start..match_end, &effective_replace);
1176 if updated == contents {
1177 return Err(ToolError::invalid_input(
1178 "search and replace resolve to identical file contents after line-ending normalization, no change intended",
1179 ));
1180 }
1181
1182 if let Some(reason) = invalid_preprocessor_edit(&file_path, &contents, &updated) {
1183 return Err(ToolError::invalid_input(format!(
1184 "edit_file refused corrupted payload: {reason}. Recovery: re-read the file and retry with a complete replace (or use apply_patch for brace-heavy multi-line edits)."
1185 )));
1186 }
1187
1188 // Fidelity: the intended replace text must appear in the updated buffer
1189 // (empty replace is a valid deletion). Catches host/tool bridges that
1190 // claim success after mangling the payload.
1191 if !effective_replace.is_empty() && !updated.contains(&effective_replace) {
1192 return Err(ToolError::execution_failed(
1193 "edit_file internal fidelity check failed: replace text missing from updated buffer — refusing write",
1194 ));
1195 }
1196
1197 crate::utils::write_atomic_workspace(&file_path, updated.as_bytes()).map_err(|e| {
1198 ToolError::execution_failed(format!("Failed to write {}: {}", file_path.display(), e))
1199 })?;
1200
1201 // #5209 — never emit a success receipt unless the on-disk write
1202 // actually applied. A fabricated "Replaced 1 occurrence" + diff is
1203 // worse than a hard error: models trust it and re-edit the same
1204 // span 3–5× before noticing nothing changed.
1205 let on_disk = fs::read_to_string(&file_path).map_err(|e| {
1206 ToolError::execution_failed(format!(
1207 "Failed to verify write to {}: {}",
1208 file_path.display(),
1209 e
1210 ))
1211 })?;
1212 if on_disk != updated {
1213 return Err(ToolError::execution_failed(format!(
1214 "edit_file write verification failed for {}: on-disk contents do not match the applied edit — refusing success receipt",
1215 file_path.display()
1216 )));
1217 }
1218
1219 context.note_file_read(&file_path);
1220
1221 let display = file_path.display().to_string();
1222 let diff = make_unified_diff(&display, &contents, &updated);
1223 let fuzz_note = match fuzz_kind {
1224 Some("indentation") => " (fuzzy indentation match)",
1225 Some("punctuation") => {
1226 " (fuzzy punctuation match — typographic quotes/dashes normalized)"
1227 }
1228 Some("line endings") => " (CRLF/LF-normalized match)",
1229 Some(other) => other,
1230 None => "",
1231 };
1232 let summary = format!("Replaced 1 occurrence in {display}{fuzz_note}");
1233 let body = if diff.is_empty() {
1234 format!("{summary}\n(no textual changes)")
1235 } else {
1236 format!("{diff}\n{summary}")
1237 };
1238
1239 // Append LSP diagnostics for the edited file when enabled (#428).
1240 let diag_block = lsp_diagnostics_for_paths(context, &[file_path]).await;
1241 let full_body = if diag_block.is_empty() {
1242 body
1243 } else {
1244 format!("{body}\n{diag_block}")
1245 };
1246
1247 // The structured receipt uses the requested workspace path instead of
1248 // the resolved host path retained by the legacy model-facing body.
1249 let receipt_diff = make_unified_diff(path_str, &contents, &updated);
1250 Ok(ToolResult::success(full_body).with_metadata(json!({
1251 "event": "file.mutation",
1252 "mutation": {
1253 "diff": receipt_diff,
1254 "files": [{ "path": path_str, "outcome": "updated" }],
1255 "renames": []
1256 }
1257 })))
1258 }
1259 }
1260
1261 /// Detect catastrophic argument corruption of brace-structured edits.
1262 ///
1263 /// Models (and some host XML/JSON bridges) occasionally deliver a `replace`
1264 /// payload where a multi-line `{ ... }` block collapsed to empty `[]` or `{}`
1265 /// while `search` still contains the full structured original. Writing that
1266 /// would brick Rust match arms / JSON objects. Fail closed with recovery text
1267 /// instead of applying the mangled payload (dogfood 2026-07-24).
1268 ///
1269 /// Unbalanced-to-unbalanced edits with the **same** brace/bracket delta are
1270 /// legitimate (e.g. adding `});` inside a nested fragment). Only a *change*
1271 /// in balance is treated as truncation/mangling. Empty-bracket collapse and
1272 /// extreme-shrinkage guards remain.
1273 fn edit_payload_looks_corrupted(search: &str, replace: &str) -> Option<&'static str> {
1274 let search_curly_open = search.matches('{').count();
1275 let search_curly_close = search.matches('}').count();
1276 let replace_curly_open = replace.matches('{').count();
1277 let replace_curly_close = replace.matches('}').count();
1278 let search_square_open = search.matches('[').count();
1279 let search_square_close = search.matches(']').count();
1280 let replace_square_open = replace.matches('[').count();
1281 let replace_square_close = replace.matches(']').count();
1282
1283 let search_curly_delta = search_curly_open as i32 - search_curly_close as i32;
1284 let replace_curly_delta = replace_curly_open as i32 - replace_curly_close as i32;
1285 let search_square_delta = search_square_open as i32 - search_square_close as i32;
1286 let replace_square_delta = replace_square_open as i32 - replace_square_close as i32;
1287
1288 // Same delta on both sides (including both unbalanced the same way) is
1289 // normal for fragment edits. Divergent deltas usually mean truncation.
1290 if search_curly_delta != replace_curly_delta {
1291 return Some(
1292 "search/replace change `{`/`}` brace balance — the tool-call arguments were likely truncated or mangled before apply",
1293 );
1294 }
1295 if search_square_delta != replace_square_delta {
1296 return Some(
1297 "search/replace change `[`/`]` bracket balance — the tool-call arguments were likely truncated or mangled before apply",
1298 );
1299 }
1300
1301 // Dogfood 2026-07-24: multi-line Rust `{ ... }` search collapsed into an
1302 // empty `[ ... ]` placeholder (host/XML arg bridge ate the brace body).
1303 // Count non-whitespace, non-bracket payload chars; a near-empty bracket
1304 // husk with a tiny tail like `=> {},` is the signature of that failure.
1305 if search_curly_open >= 1 && replace_square_open >= 1 {
1306 let significant = replace
1307 .chars()
1308 .filter(|c| !c.is_whitespace() && *c != '[' && *c != ']')
1309 .count();
1310 if significant <= 12 {
1311 return Some(
1312 "replace collapsed a brace-structured search block into an empty/placeholder bracket span — refusing to brick the file; re-send the full replace text (prefer apply_patch for multi-line match arms)",
1313 );
1314 }
1315 }
1316
1317 // Extreme shrinkage with lost braces (e.g. 200-char match arm -> tiny stub).
1318 // Balanced-to-balanced nesting changes that shrink hard still look like
1319 // mangling; keep this guard even when deltas match.
1320 if search.len() >= 80
1321 && replace.len() * 8 < search.len()
1322 && search_curly_open >= 1
1323 && replace_curly_open < search_curly_open
1324 {
1325 return Some(
1326 "replace is drastically shorter than search and lost brace structure — likely argument mangling; refuse apply",
1327 );
1328 }
1329
1330 None
1331 }
1332
1333 const PREPROCESSOR_CONDITIONAL_ERROR: &str = "replace would change the C/C++ preprocessor conditional balance (#if/#ifdef/#ifndef vs #endif) — the search or replace text is missing a matching directive; copy the complete block including both its opening and closing directives";
1334
1335 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1336 struct PreprocessorConditionalDebt {
1337 orphaned_closes: usize,
1338 unclosed_opens: usize,
1339 }
1340
1341 impl PreprocessorConditionalDebt {
1342 fn total(self) -> usize {
1343 self.orphaned_closes + self.unclosed_opens
1344 }
1345 }
1346
1347 /// Reject an edit only when it introduces new conditional-structure damage in
1348 /// a file whose extension identifies it as C-family source. The whole file is
1349 /// checked before and after the edit: complete block insertion/removal is safe,
1350 /// while an orphaned opener or closer increases the structural debt. Existing
1351 /// debt may be preserved or reduced so this guard never prevents a repair.
1352 fn invalid_preprocessor_edit(path: &Path, before: &str, after: &str) -> Option<&'static str> {
1353 if !is_c_family_source(path) {
1354 return None;
1355 }
1356
1357 let before_debt = preprocessor_conditional_debt(before);
1358 let after_debt = preprocessor_conditional_debt(after);
1359 let safe = after_debt == before_debt
1360 || after_debt.total() == 0
1361 || after_debt.total() < before_debt.total();
1362
1363 (!safe).then_some(PREPROCESSOR_CONDITIONAL_ERROR)
1364 }
1365
1366 fn is_c_family_source(path: &Path) -> bool {
1367 const EXTENSIONS: &[&str] = &[
1368 "c", "cc", "cp", "cpp", "cxx", "h", "h++", "hh", "hpp", "hxx", "inl", "ipp", "ixx", "m",
1369 "mm", "tpp", "cu", "cuh", "cppm",
1370 ];
1371
1372 path.extension()
1373 .and_then(|extension| extension.to_str())
1374 .is_some_and(|extension| {
1375 EXTENSIONS
1376 .iter()
1377 .any(|candidate| extension.eq_ignore_ascii_case(candidate))
1378 })
1379 }
1380
1381 /// Measure unmatched preprocessor conditionals across an entire source file.
1382 /// Tracking nesting (instead of comparing span-level tuple counts) also catches
1383 /// an `#endif` moved before its opener. Whitespace between `#` and the directive
1384 /// name is accepted, as it is by C preprocessors.
1385 fn preprocessor_conditional_debt(text: &str) -> PreprocessorConditionalDebt {
1386 let mut depth = 0usize;
1387 let mut orphaned_closes = 0usize;
1388
1389 for line in text.lines() {
1390 match preprocessor_directive(line) {
1391 Some("if" | "ifdef" | "ifndef") => depth += 1,
1392 Some("endif") if depth == 0 => orphaned_closes += 1,
1393 Some("endif") => depth -= 1,
1394 _ => {}
1395 }
1396 }
1397
1398 PreprocessorConditionalDebt {
1399 orphaned_closes,
1400 unclosed_opens: depth,
1401 }
1402 }
1403
1404 fn preprocessor_directive(line: &str) -> Option<&str> {
1405 let rest = line.trim_start().strip_prefix('#')?.trim_start();
1406 let name_end = rest
1407 .find(|character: char| !character.is_ascii_alphabetic())
1408 .unwrap_or(rest.len());
1409 (name_end > 0).then_some(&rest[..name_end])
1410 }
1411
1412 /// Build a short, line-truncated preview of a (possibly very long) search
1413 /// payload for error messages, so the model can compare what it searched for
1414 /// against the file's actual contents without the error message ballooning.
1415 fn preview_search_for_error(search: &str) -> String {
1416 const MAX_PREVIEW_LINES: usize = 3;
1417 const MAX_PREVIEW_LINE_LEN: usize = 80;
1418 search
1419 .lines()
1420 .take(MAX_PREVIEW_LINES)
1421 .map(|line| {
1422 if line.chars().count() > MAX_PREVIEW_LINE_LEN {
1423 let mut truncated: String = line.chars().take(MAX_PREVIEW_LINE_LEN).collect();
1424 truncated.push_str("...");
1425 truncated
1426 } else {
1427 line.to_string()
1428 }
1429 })
1430 .collect::<Vec<_>>()
1431 .join("\n")
1432 }
1433
1434 /// Normalize Windows CRLF pairs to LF while retaining the normalized byte
1435 /// positions where a `\r` was removed. Lone carriage returns are preserved.
1436 /// Inputs without CRLF are borrowed and use identity offsets.
1437 ///
1438 /// A normalized boundary maps back to the original by adding the number of
1439 /// removed CR bytes strictly before it. At the normalized newline itself that
1440 /// excludes the current CR, so the start maps to `\r`; after the newline (or
1441 /// at EOF) it includes that CR and spans the full pair.
1442 fn normalize_crlf(input: &str) -> Cow<'_, str> {
1443 if input.contains("\r\n") {
1444 Cow::Owned(input.replace("\r\n", "\n"))
1445 } else {
1446 Cow::Borrowed(input)
1447 }
1448 }
1449
1450 fn normalize_crlf_with_positions(input: &str) -> (Cow<'_, str>, Option<Vec<usize>>) {
1451 if !input.contains("\r\n") {
1452 return (Cow::Borrowed(input), None);
1453 }
1454
1455 let mut normalized = String::with_capacity(input.len());
1456 let mut crlf_positions = Vec::new();
1457 let mut chars = input.char_indices().peekable();
1458
1459 while let Some((_, ch)) = chars.next() {
1460 if ch == '\r' && matches!(chars.peek(), Some((_, '\n'))) {
1461 let _ = chars.next();
1462 crlf_positions.push(normalized.len());
1463 normalized.push('\n');
1464 continue;
1465 }
1466
1467 normalized.push(ch);
1468 }
1469
1470 (Cow::Owned(normalized), Some(crlf_positions))
1471 }
1472
1473 fn map_normalized_range(
1474 (start, end): (usize, usize),
1475 crlf_positions: Option<&[usize]>,
1476 ) -> (usize, usize) {
1477 let Some(crlf_positions) = crlf_positions else {
1478 return (start, end);
1479 };
1480 let map_boundary =
1481 |offset| offset + crlf_positions.partition_point(|position| *position < offset);
1482 (map_boundary(start), map_boundary(end))
1483 }
1484
1485 fn map_normalized_ranges(
1486 ranges: impl IntoIterator<Item = (usize, usize)>,
1487 crlf_positions: Option<&[usize]>,
1488 ) -> Vec<(usize, usize)> {
1489 ranges
1490 .into_iter()
1491 .map(|range| map_normalized_range(range, crlf_positions))
1492 .collect()
1493 }
1494
1495 /// Convert model-provided replacement newlines to the base file's convention.
1496 /// Fold CRLF first so an already-CRLF payload never becomes `\r\r\n`.
1497 fn normalize_replacement_line_endings(replace: &str, use_crlf: bool) -> String {
1498 let lf = replace.replace("\r\n", "\n");
1499 if use_crlf {
1500 lf.replace('\n', "\r\n")
1501 } else {
1502 lf
1503 }
1504 }
1505
1506 fn strip_line_leading_whitespace_with_map(input: &str) -> (String, Vec<usize>) {
1507 let mut normalized = String::with_capacity(input.len());
1508 let mut byte_map = Vec::with_capacity(input.len());
1509 let mut at_line_start = true;
1510 for (idx, ch) in input.char_indices() {
1511 if at_line_start && matches!(ch, ' ' | '\t') {
1512 continue;
1513 }
1514 normalized.push(ch);
1515 for _ in 0..ch.len_utf8() {
1516 byte_map.push(idx);
1517 }
1518 at_line_start = ch == '\n';
1519 }
1520 (normalized, byte_map)
1521 }
1522
1523 fn line_start_before(input: &str, idx: usize) -> usize {
1524 input[..idx]
1525 .rfind('\n')
1526 .map_or(0, |newline| newline.saturating_add(1))
1527 }
1528
1529 fn next_char_boundary(input: &str, idx: usize) -> usize {
1530 if idx >= input.len() {
1531 return input.len();
1532 }
1533
1534 let mut next = idx.saturating_add(1);
1535 while next < input.len() && !input.is_char_boundary(next) {
1536 next = next.saturating_add(1);
1537 }
1538 next
1539 }
1540
1541 fn leading_whitespace_fuzzy_matches(contents: &str, search: &str) -> Vec<(usize, usize)> {
1542 let (normalized_contents, byte_map) = strip_line_leading_whitespace_with_map(contents);
1543 let (normalized_search, _) = strip_line_leading_whitespace_with_map(search);
1544 if normalized_search.is_empty() {
1545 return Vec::new();
1546 }
1547
1548 let mut matches = Vec::new();
1549 let mut cursor = 0;
1550 while let Some(rel_idx) = normalized_contents[cursor..].find(&normalized_search) {
1551 let norm_start = cursor + rel_idx;
1552 let norm_end = norm_start + normalized_search.len();
1553 let Some(&mapped_start) = byte_map.get(norm_start) else {
1554 break;
1555 };
1556 // Use the actual match start position, expanding to line start only
1557 // when the match begins at a line boundary in the normalized text.
1558 // This prevents destroying preceding text on the same line when
1559 // the match starts mid-line after whitespace stripping.
1560 let original_start =
1561 if norm_start == 0 || normalized_contents.as_bytes()[norm_start - 1] == b'\n' {
1562 // Match starts at a line boundary — use line start for full-line replacement.
1563 line_start_before(contents, mapped_start)
1564 } else {
1565 // Match starts mid-line — use the exact mapped position.
1566 mapped_start
1567 };
1568 let original_end = byte_map.get(norm_end).copied().unwrap_or(contents.len());
1569 matches.push((original_start, original_end));
1570 cursor = next_char_boundary(&normalized_contents, norm_start);
1571 }
1572 matches
1573 }
1574
1575 /// Normalize typographic punctuation to its ASCII counterpart:
1576 ///
1577 /// * `"` `"` / U+201C U+201D → `"`
1578 /// * `'` `'` / U+2018 U+2019 → `'`
1579 /// * `–` `—` / U+2013 U+2014 → `-`
1580 /// * U+00A0 (non-breaking space) → ASCII space
1581 ///
1582 /// Returns the normalized string plus a byte-map sized to
1583 /// `normalized.len()` whose i-th entry is the original byte offset of
1584 /// the character that produced normalized byte i. Used to recover the
1585 /// original-byte range after finding a match in normalized space.
1586 fn punctuation_normalized_with_map(input: &str) -> (String, Vec<usize>) {
1587 let mut normalized = String::with_capacity(input.len());
1588 let mut byte_map = Vec::with_capacity(input.len());
1589 for (idx, ch) in input.char_indices() {
1590 let replacement: Option<char> = match ch {
1591 '\u{201C}' | '\u{201D}' => Some('"'),
1592 '\u{2018}' | '\u{2019}' => Some('\''),
1593 '\u{2013}' | '\u{2014}' => Some('-'),
1594 '\u{00A0}' => Some(' '),
1595 _ => None,
1596 };
1597 let written = replacement.unwrap_or(ch);
1598 normalized.push(written);
1599 for _ in 0..written.len_utf8() {
1600 byte_map.push(idx);
1601 }
1602 }
1603 (normalized, byte_map)
1604 }
1605
1606 /// Try to find `search` inside `contents` after normalizing typographic
1607 /// punctuation in both. Catches the copy-paste failure mode where a
1608 /// browser, word processor, or chat client silently converted ASCII
1609 /// quotes/dashes to their Unicode "pretty" forms.
1610 fn punctuation_normalized_matches(contents: &str, search: &str) -> Vec<(usize, usize)> {
1611 let (norm_contents, byte_map) = punctuation_normalized_with_map(contents);
1612 let (norm_search, _) = punctuation_normalized_with_map(search);
1613 if norm_search.is_empty() {
1614 return Vec::new();
1615 }
1616 // If normalization didn't change anything, the exact-match pass
1617 // already considered this case — skip to avoid double-reporting.
1618 if norm_contents == contents && norm_search == search {
1619 return Vec::new();
1620 }
1621
1622 let mut matches = Vec::new();
1623 let mut cursor = 0;
1624 while let Some(rel_idx) = norm_contents[cursor..].find(&norm_search) {
1625 let norm_start = cursor + rel_idx;
1626 let norm_end = norm_start + norm_search.len();
1627 let Some(&original_start) = byte_map.get(norm_start) else {
1628 break;
1629 };
1630 let original_end = byte_map.get(norm_end).copied().unwrap_or(contents.len());
1631 matches.push((original_start, original_end));
1632 cursor = next_char_boundary(&norm_contents, norm_start);
1633 }
1634 matches
1635 }
1636
1637 // === ListDirTool ===
1638
1639 /// Tool for listing directory contents.
1640 pub struct ListDirTool;
1641
1642 const LIST_DIR_TIMEOUT: Duration = Duration::from_secs(30);
1643
1644 /// Cap on entries returned by a single `list_dir` call so a huge directory
1645 /// (node_modules, build output, photo dumps) can't balloon the tool result.
1646 /// Mirrors the bounded-output idiom of `read_file`'s `HARD_MAX_READ_LINES`.
1647 /// Directories at or under the cap keep the historical plain-array response;
1648 /// larger ones return an object with truncation metadata.
1649 const LIST_DIR_MAX_ENTRIES: usize = 500;
1650
1651 #[async_trait]
1652 impl ToolSpec for ListDirTool {
1653 fn name(&self) -> &'static str {
1654 "list_dir"
1655 }
1656
1657 fn model_visible(&self) -> bool {
1658 false
1659 }
1660
1661 fn description(&self) -> &'static str {
1662 "List entries in a directory relative to the workspace. Use this instead of `ls`, `ls -la`, or `find . -maxdepth 1` in `Bash` for directory listings."
1663 }
1664
1665 fn input_schema(&self) -> Value {
1666 json!({
1667 "type": "object",
1668 "properties": {
1669 "path": {
1670 "type": "string",
1671 "description": "Relative path (default: .)"
1672 }
1673 },
1674 "required": []
1675 })
1676 }
1677
1678 fn capabilities(&self) -> Vec<ToolCapability> {
1679 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
1680 }
1681
1682 fn supports_parallel(&self) -> bool {
1683 true
1684 }
1685
1686 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1687 let mut input = input;
1688 apply_param_aliases(&mut input, PATH_ALIASES, "File list")?;
1689 LIST_PARAMS.reject_unknown(&input)?;
1690
1691 let path_str = optional_str(&input, "path")?.unwrap_or(".");
1692 let dir_path = context.resolve_path(path_str)?;
1693
1694 let entries =
1695 list_dir_entries_async(dir_path, context.cancel_token.clone(), LIST_DIR_TIMEOUT)
1696 .await?;
1697
1698 ToolResult::json(&entries).map_err(|e| ToolError::execution_failed(e.to_string()))
1699 }
1700 }
1701
1702 async fn list_dir_entries_async(
1703 dir_path: PathBuf,
1704 cancel_token: Option<CancellationToken>,
1705 timeout: Duration,
1706 ) -> Result<Value, ToolError> {
1707 let worker_cancel_token = cancel_token.clone();
1708 run_blocking_list_dir(timeout, cancel_token, move || {
1709 list_dir_entries(&dir_path, worker_cancel_token.as_ref())
1710 })
1711 .await
1712 }
1713
1714 async fn run_blocking_list_dir<F>(
1715 timeout: Duration,
1716 cancel_token: Option<CancellationToken>,
1717 list_dir: F,
1718 ) -> Result<Value, ToolError>
1719 where
1720 F: FnOnce() -> Result<Value, ToolError> + Send + 'static,
1721 {
1722 if cancel_token
1723 .as_ref()
1724 .is_some_and(CancellationToken::is_cancelled)
1725 {
1726 return Err(list_dir_cancelled());
1727 }
1728
1729 let task = tokio::task::spawn_blocking(list_dir);
1730 let result = match cancel_token {
1731 Some(token) => {
1732 tokio::select! {
1733 biased;
1734 () = token.cancelled() => return Err(list_dir_cancelled()),
1735 result = tokio::time::timeout(timeout, task) => result,
1736 }
1737 }
1738 None => tokio::time::timeout(timeout, task).await,
1739 };
1740
1741 let joined = result.map_err(|_| list_dir_timeout(timeout))?;
1742 joined.map_err(|err| {
1743 ToolError::execution_failed(format!("list_dir worker failed before completion: {err}"))
1744 })?
1745 }
1746
1747 fn list_dir_entries(
1748 dir_path: &Path,
1749 cancel_token: Option<&CancellationToken>,
1750 ) -> Result<Value, ToolError> {
1751 check_list_dir_cancelled(cancel_token)?;
1752
1753 let mut entries = Vec::new();
1754 let mut total_entries = 0usize;
1755
1756 for entry in fs::read_dir(dir_path).map_err(|e| {
1757 ToolError::execution_failed(format!(
1758 "Failed to read directory {}: {}",
1759 dir_path.display(),
1760 e
1761 ))
1762 })? {
1763 check_list_dir_cancelled(cancel_token)?;
1764
1765 let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
1766 total_entries += 1;
1767 // Past the cap, keep counting for the truncation metadata but stop
1768 // materializing entries.
1769 if entries.len() >= LIST_DIR_MAX_ENTRIES {
1770 continue;
1771 }
1772 let file_type = entry
1773 .file_type()
1774 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
1775
1776 entries.push(json!({
1777 "name": entry.file_name().to_string_lossy().to_string(),
1778 "is_dir": file_type.is_dir(),
1779 }));
1780 }
1781
1782 if total_entries > entries.len() {
1783 Ok(json!({
1784 "entries": entries,
1785 "listed_entries": LIST_DIR_MAX_ENTRIES,
1786 "total_entries": total_entries,
1787 "truncated": true,
1788 }))
1789 } else {
1790 Ok(Value::Array(entries))
1791 }
1792 }
1793
1794 fn check_list_dir_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
1795 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
1796 return Err(list_dir_cancelled());
1797 }
1798 Ok(())
1799 }
1800
1801 fn list_dir_cancelled() -> ToolError {
1802 ToolError::cancelled("list_dir cancelled before completion")
1803 }
1804
1805 fn list_dir_timeout(timeout: Duration) -> ToolError {
1806 ToolError::Timeout {
1807 seconds: timeout.as_secs().max(1),
1808 }
1809 }
1810
1811 // === Unit Tests ===
1812
1813 #[cfg(test)]
1814 #[path = "file/tests.rs"]
1815 mod pdf_tests;
1816
1817 #[cfg(test)]
1818 #[path = "file/tests/tools.rs"]
1819 mod tests;
1820
1820 lines RUST