From 13baceeef1d63ab4ad694a775ef784c019691d89 Mon Sep 17 00:00:00 2001 From: Tomas Carnecky Date: Tue, 9 Jun 2026 23:42:52 +0200 Subject: [PATCH] Add edit tool --- bin/pudox-run/src/main.rs | 166 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/bin/pudox-run/src/main.rs b/bin/pudox-run/src/main.rs index 7c6e9b8..aec5348 100644 --- a/bin/pudox-run/src/main.rs +++ b/bin/pudox-run/src/main.rs @@ -250,6 +250,41 @@ async fn process_task( .build()?, })); + tools.push(ChatCompletionTools::Function(ChatCompletionTool { + function: FunctionObjectArgs::default() + .name("edit") + .description("Make targeted replacements in a file. Prefer over 'write' for surgical changes to existing files. Batch multiple disjoint edits into one call via the edits array.") + .parameters(serde_json::json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to edit." + }, + "edits": { + "type": "array", + "description": "One or more replacements. Each oldText must uniquely identify a location; include 1-2 surrounding lines if needed to disambiguate.", + "items": { + "type": "object", + "properties": { + "oldText": { + "type": "string", + "description": "Exact text to find and replace." + }, + "newText": { + "type": "string", + "description": "Replacement text." + } + }, + "required": ["oldText", "newText"] + } + } + }, + "required": ["path", "edits"] + })) + .build()?, + })); + tools.push(ChatCompletionTools::Function(ChatCompletionTool { function: FunctionObjectArgs::default() .name("finish") @@ -561,6 +596,17 @@ async fn handle_tool_calls( ); } + "edit" => { + let output = tool_edit(&f.function.arguments); + tracing::info!("edit: {}", output); + results.push( + ChatCompletionRequestToolMessageArgs::default() + .tool_call_id(f.id.clone()) + .content(output) + .build()?, + ); + } + "shell" => { let output = tool_shell(&f.function.arguments).await; tracing::info!("shell: {} chars", output.len()); @@ -641,6 +687,126 @@ fn tool_write(arguments: &str) -> String { } } +fn normalize_for_match(s: &str) -> String { + s.split_whitespace() + .collect::>() + .join(" ") + .replace(['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}'], "\"") +} + +/// Returns the byte range `[start, end)` of `old_text` within `content`. +/// Tries exact match first, then a line-by-line whitespace/quote-normalized match. +fn find_edit_range(content: &str, old_text: &str) -> Result<(usize, usize), String> { + if old_text.is_empty() { + return Err("oldText must not be empty".to_string()); + } + + // Fast path: exact match + if let Some(pos) = content.find(old_text) { + let rest = &content[pos + old_text.len()..]; + if rest.contains(old_text) { + return Err( + "oldText matches multiple locations; add surrounding lines to disambiguate" + .to_string(), + ); + } + return Ok((pos, pos + old_text.len())); + } + + // Slow path: normalized line-by-line + let content_lines: Vec<&str> = content.lines().collect(); + let old_lines: Vec<&str> = old_text.lines().collect(); + + let mut match_starts: Vec = Vec::new(); + 'outer: for i in 0..=content_lines.len().saturating_sub(old_lines.len()) { + for (j, old_line) in old_lines.iter().enumerate() { + if normalize_for_match(content_lines[i + j]) != normalize_for_match(old_line) { + continue 'outer; + } + } + match_starts.push(i); + } + + match match_starts.len() { + 0 => Err("oldText not found in file (exact and normalized searches failed)".to_string()), + 1 => { + let start_line = match_starts[0]; + let end_line = start_line + old_lines.len(); + // Compute byte offsets by iterating lines with their endings. + let mut byte_start = 0usize; + let mut byte_end = 0usize; + let mut cur = 0usize; + for (i, line) in content.lines().enumerate() { + if i == start_line { + byte_start = cur; + } + // lines() strips line endings, so advance past the line + its ending + let ending_len = if content[cur..].starts_with('\r') { + 2 + } else { + 1 + }; + cur += line.len(); + if cur < content.len() { + cur += ending_len; + } + if i + 1 == end_line { + byte_end = cur; + break; + } + } + Ok((byte_start, byte_end.min(content.len()))) + } + n => Err(format!( + "oldText (normalized) matches {n} locations; add surrounding lines to disambiguate" + )), + } +} + +fn tool_edit(arguments: &str) -> String { + let args: serde_json::Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(e) => return format!("Error parsing arguments: {}", e), + }; + let path = match args["path"].as_str() { + Some(p) => p, + None => return "Missing required argument: path".to_string(), + }; + let edits = match args["edits"].as_array() { + Some(e) => e, + None => return "Missing required argument: edits".to_string(), + }; + + let mut content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) => return format!("Error reading {}: {}", path, e), + }; + + let mut applied = 0usize; + for (idx, edit) in edits.iter().enumerate() { + let old_text = match edit["oldText"].as_str() { + Some(s) => s, + None => return format!("edits[{}]: missing oldText", idx), + }; + let new_text = match edit["newText"].as_str() { + Some(s) => s, + None => return format!("edits[{}]: missing newText", idx), + }; + match find_edit_range(&content, old_text) { + Ok((start, end)) => { + content = format!("{}{}{}", &content[..start], new_text, &content[end..]); + applied += 1; + } + Err(e) => return format!("edits[{}]: {}", idx, e), + } + } + + match std::fs::write(path, &content) { + Ok(_) => format!("Applied {} edit(s) to {}", applied, path), + Err(e) => format!("Error writing {}: {}", path, e), + } +} + async fn tool_shell(arguments: &str) -> String { let args: serde_json::Value = match serde_json::from_str(arguments) { Ok(v) => v, -- 2.51.2