Accept whole file edit content
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
9eed110fbbed74910c4a9a67c375104d56cf92a2- Parents
-
5769ad8 - Tree
753e097
9eed110
9eed110fbbed74910c4a9a67c375104d56cf92a25769ad8
753e097| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/dod.py
|
6 | 2 |
| M |
src/loader/tools/file_tools.py
|
64 | 3 |
| M |
tests/test_dod.py
|
20 | 0 |
| M |
tests/test_tools.py
|
11 | 0 |
src/loader/runtime/dod.pymodified@@ -281,9 +281,13 @@ def record_successful_tool_call( | ||
| 281 | 281 | _append_unique(dod.touched_files, file_path) |
| 282 | 282 | dod.line_changes += _count_lines(content) |
| 283 | 283 | elif tool_call.name == "edit": |
| 284 | - file_path = _resolve_touched_path(tool_call.arguments.get("file_path", "")) | |
| 284 | + file_path = _resolve_touched_path( | |
| 285 | + tool_call.arguments.get("file_path") or tool_call.arguments.get("path", "") | |
| 286 | + ) | |
| 285 | 287 | old_string = str(tool_call.arguments.get("old_string", "")) |
| 286 | - new_string = str(tool_call.arguments.get("new_string", "")) | |
| 288 | + new_string = str( | |
| 289 | + tool_call.arguments.get("new_string", tool_call.arguments.get("content", "")) | |
| 290 | + ) | |
| 287 | 291 | if file_path: |
| 288 | 292 | _append_unique(dod.touched_files, file_path) |
| 289 | 293 | dod.line_changes += max(_count_lines(old_string), _count_lines(new_string)) |
src/loader/tools/file_tools.pymodified@@ -346,12 +346,28 @@ class EditTool(Tool): | ||
| 346 | 346 | |
| 347 | 347 | async def execute( |
| 348 | 348 | self, |
| 349 | - file_path: str, | |
| 350 | - old_string: str, | |
| 351 | - new_string: str, | |
| 349 | + file_path: str | None = None, | |
| 350 | + old_string: str | None = None, | |
| 351 | + new_string: str | None = None, | |
| 352 | 352 | **kwargs: Any, |
| 353 | 353 | ) -> ToolResult: |
| 354 | 354 | kwargs.pop("_skip_confirmation", None) |
| 355 | + if file_path is None: | |
| 356 | + file_path = str(kwargs.pop("path", "") or "") | |
| 357 | + replacement_content = kwargs.pop("content", None) | |
| 358 | + if replacement_content is not None and ( | |
| 359 | + old_string is None or new_string is None | |
| 360 | + ): | |
| 361 | + return await self._replace_file_content( | |
| 362 | + file_path, | |
| 363 | + str(replacement_content), | |
| 364 | + ) | |
| 365 | + if old_string is None or new_string is None: | |
| 366 | + return ToolResult( | |
| 367 | + "edit requires file_path, old_string, and new_string. " | |
| 368 | + "For whole-file replacement, provide path/file_path with content.", | |
| 369 | + is_error=True, | |
| 370 | + ) | |
| 355 | 371 | try: |
| 356 | 372 | path = resolve_workspace_path( |
| 357 | 373 | file_path, |
@@ -426,6 +442,51 @@ class EditTool(Tool): | ||
| 426 | 442 | except Exception as e: |
| 427 | 443 | return ToolResult(f"Error editing file: {e}", is_error=True) |
| 428 | 444 | |
| 445 | + async def _replace_file_content( | |
| 446 | + self, | |
| 447 | + file_path: str, | |
| 448 | + new_content: str, | |
| 449 | + ) -> ToolResult: | |
| 450 | + try: | |
| 451 | + path = resolve_workspace_path( | |
| 452 | + file_path, | |
| 453 | + workspace_root=self.workspace_root, | |
| 454 | + ) | |
| 455 | + except FileNotFoundError: | |
| 456 | + return ToolResult(f"File not found: {file_path}", is_error=True) | |
| 457 | + except PermissionError: | |
| 458 | + return ToolResult( | |
| 459 | + "Whole-file edit target is outside the workspace root.", | |
| 460 | + is_error=True, | |
| 461 | + ) | |
| 462 | + except Exception as exc: | |
| 463 | + return ToolResult(f"Error resolving file path: {exc}", is_error=True) | |
| 464 | + | |
| 465 | + if not path.exists(): | |
| 466 | + return ToolResult(f"File not found: {file_path}", is_error=True) | |
| 467 | + | |
| 468 | + try: | |
| 469 | + ensure_safe_to_read(path) | |
| 470 | + original_content = await asyncio.to_thread(path.read_text) | |
| 471 | + ensure_safe_to_write(new_content) | |
| 472 | + await asyncio.to_thread(path.write_text, new_content) | |
| 473 | + structured_patch = [ | |
| 474 | + hunk.to_dict() | |
| 475 | + for hunk in make_structured_patch(original_content, new_content) | |
| 476 | + ] | |
| 477 | + return ToolResult( | |
| 478 | + f"Successfully edited {path}", | |
| 479 | + metadata={ | |
| 480 | + "file_path": str(path), | |
| 481 | + "old_string": original_content, | |
| 482 | + "new_string": new_content, | |
| 483 | + "original_file": original_content, | |
| 484 | + "structured_patch": structured_patch, | |
| 485 | + }, | |
| 486 | + ) | |
| 487 | + except Exception as exc: | |
| 488 | + return ToolResult(f"Error editing file: {exc}", is_error=True) | |
| 489 | + | |
| 429 | 490 | |
| 430 | 491 | class PatchTool(Tool): |
| 431 | 492 | """Edit a file by applying structured patch hunks.""" |
tests/test_dod.pymodified@@ -143,6 +143,26 @@ def test_record_successful_tool_call_counts_json_string_patch_hunks( | ||
| 143 | 143 | assert dod.line_changes == 8 |
| 144 | 144 | |
| 145 | 145 | |
| 146 | +def test_record_successful_tool_call_counts_path_content_edit(tmp_path: Path) -> None: | |
| 147 | + dod = create_definition_of_done("Replace generated HTML content.") | |
| 148 | + target = tmp_path / "index.html" | |
| 149 | + | |
| 150 | + record_successful_tool_call( | |
| 151 | + dod, | |
| 152 | + ToolCall( | |
| 153 | + id="edit-1", | |
| 154 | + name="edit", | |
| 155 | + arguments={ | |
| 156 | + "path": str(target), | |
| 157 | + "content": "<h1>Guide</h1>\n<p>Expanded.</p>\n", | |
| 158 | + }, | |
| 159 | + ), | |
| 160 | + ) | |
| 161 | + | |
| 162 | + assert dod.touched_files == [str(target)] | |
| 163 | + assert dod.line_changes == 3 | |
| 164 | + | |
| 165 | + | |
| 146 | 166 | def test_derive_verification_commands_adds_semantic_html_toc_check(tmp_path: Path) -> None: |
| 147 | 167 | chapters = tmp_path / "chapters" |
| 148 | 168 | chapters.mkdir() |
tests/test_tools.pymodified@@ -107,6 +107,17 @@ class TestEditTool: | ||
| 107 | 107 | assert not result.is_error |
| 108 | 108 | assert "Modified Line 2" in sample_file.read_text() |
| 109 | 109 | |
| 110 | + @pytest.mark.asyncio | |
| 111 | + async def test_edit_accepts_path_content_replacement(self, tool, sample_file): | |
| 112 | + result = await tool.execute( | |
| 113 | + path=str(sample_file), | |
| 114 | + content="Replacement file\nwith details\n", | |
| 115 | + ) | |
| 116 | + | |
| 117 | + assert not result.is_error | |
| 118 | + assert sample_file.read_text() == "Replacement file\nwith details\n" | |
| 119 | + assert result.metadata["structured_patch"] | |
| 120 | + | |
| 110 | 121 | @pytest.mark.asyncio |
| 111 | 122 | async def test_edit_nonexistent(self, tool, temp_dir): |
| 112 | 123 | result = await tool.execute( |