From 225a383690d50e2aa3fa60af43c73975a738d341 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 8 Mar 2026 01:11:19 -0500 Subject: [PATCH] Include file contents in apply_patch error responses When apply_patch fails because a context anchor line doesn't match, the model had no visibility into what the file actually contained and would retry blindly. Now the error includes numbered file contents (truncated to 9K chars via HeadTail mode) so the model can self-correct in one try. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/arc-agent/src/profiles/openai.rs | 4 +- crates/arc-agent/src/v4a_patch.rs | 66 ++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/arc-agent/src/profiles/openai.rs b/crates/arc-agent/src/profiles/openai.rs index 94e8a4f75..ddc17fc11 100644 --- a/crates/arc-agent/src/profiles/openai.rs +++ b/crates/arc-agent/src/profiles/openai.rs @@ -119,8 +119,8 @@ If completing the task requires writing or modifying files: and focused on the task. - Use `git log` and `git blame` to search the history of the codebase if additional context is needed. - NEVER add copyright or license headers unless specifically requested. -- Do not waste tokens re-reading files after calling apply_patch on them. The tool call will \ -fail if it did not work. +- When apply_patch fails, the error includes the current file contents — use them to construct \ +a corrected patch without re-reading the file. - Do not `git commit` your changes or create new git branches unless explicitly requested. # Validating Your Work diff --git a/crates/arc-agent/src/v4a_patch.rs b/crates/arc-agent/src/v4a_patch.rs index 77ec6d4b9..a192c8f82 100644 --- a/crates/arc-agent/src/v4a_patch.rs +++ b/crates/arc-agent/src/v4a_patch.rs @@ -1,5 +1,6 @@ use crate::sandbox::Sandbox; use crate::tool_registry::RegisteredTool; +use crate::truncation::{truncate_output, TruncationMode}; use arc_llm::types::ToolDefinition; use std::sync::Arc; @@ -152,7 +153,8 @@ pub async fn apply_patch_operations( } PatchOperation::Update { path, hunks } => { let original = env.read_file(path, None, None).await?; - let updated = apply_hunks(&original, hunks)?; + let updated = apply_hunks(&original, hunks) + .map_err(|err| format_patch_error(&err, path, &original))?; env.write_file(path, &updated).await?; results.push(format!("Updated file: {path}")); } @@ -241,6 +243,17 @@ fn apply_hunks(content: &str, hunks: &[Hunk]) -> Result { Ok(lines.join("\n")) } +fn format_patch_error(error: &str, path: &str, content: &str) -> String { + let numbered: String = content + .lines() + .enumerate() + .map(|(i, line)| format!("{}|{}", i + 1, line)) + .collect::>() + .join("\n"); + let truncated = truncate_output(&numbered, 9_000, TruncationMode::HeadTail); + format!("{error}\n\nCurrent contents of {path}:\n{truncated}") +} + pub fn make_apply_patch_tool() -> RegisteredTool { RegisteredTool { definition: ToolDefinition { @@ -647,4 +660,55 @@ mod tests { assert!(content.contains("println!(\"new\")")); assert!(!content.contains("println!(\"old\")")); } + + #[test] + fn format_patch_error_includes_numbered_contents() { + let result = format_patch_error( + "Could not find context line in file: 'fn missing()'", + "src/lib.rs", + "fn hello() {\n println!(\"hi\");\n}", + ); + assert!(result.contains("Could not find context line in file: 'fn missing()'")); + assert!(result.contains("Current contents of src/lib.rs:")); + assert!(result.contains("1|fn hello() {")); + assert!(result.contains("2| println!(\"hi\");")); + assert!(result.contains("3|}")); + } + + #[test] + fn format_patch_error_truncates_large_files() { + let lines: Vec = (1..=1_000) + .map(|i| format!("line number {:04}", i)) + .collect(); + let content = lines.join("\n"); + let result = format_patch_error("some error", "big.txt", &content); + assert!(result.len() < 10_000); + assert!(result.contains("truncated") || result.contains("removed")); + } + + #[tokio::test] + async fn apply_patch_error_includes_file_contents() { + let mut files = HashMap::new(); + files.insert( + "src/game.py".to_string(), + "def real_fn():\n pass".to_string(), + ); + let env = MutableMockSandbox::new(files); + + let ops = vec![PatchOperation::Update { + path: "src/game.py".into(), + hunks: vec![Hunk { + context_line: "def nonexistent():".into(), + changes: vec![ + Change::Remove(" old_body()".into()), + Change::Add(" new_body()".into()), + ], + }], + }]; + + let err = apply_patch_operations(&ops, &env).await.unwrap_err(); + assert!(err.contains("Could not find context line")); + assert!(err.contains("Current contents of src/game.py:")); + assert!(err.contains("1|def real_fn():")); + } }