tenseleyflow/loader / 9eed110

Browse files

Accept whole file edit content

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9eed110fbbed74910c4a9a67c375104d56cf92a2
Parents
5769ad8
Tree
753e097

4 changed files

StatusFile+-
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(
281281
             _append_unique(dod.touched_files, file_path)
282282
         dod.line_changes += _count_lines(content)
283283
     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
+        )
285287
         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
+        )
287291
         if file_path:
288292
             _append_unique(dod.touched_files, file_path)
289293
         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):
346346
 
347347
     async def execute(
348348
         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,
352352
         **kwargs: Any,
353353
     ) -> ToolResult:
354354
         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
+            )
355371
         try:
356372
             path = resolve_workspace_path(
357373
                 file_path,
@@ -426,6 +442,51 @@ class EditTool(Tool):
426442
         except Exception as e:
427443
             return ToolResult(f"Error editing file: {e}", is_error=True)
428444
 
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
+
429490
 
430491
 class PatchTool(Tool):
431492
     """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(
143143
     assert dod.line_changes == 8
144144
 
145145
 
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
+
146166
 def test_derive_verification_commands_adds_semantic_html_toc_check(tmp_path: Path) -> None:
147167
     chapters = tmp_path / "chapters"
148168
     chapters.mkdir()
tests/test_tools.pymodified
@@ -107,6 +107,17 @@ class TestEditTool:
107107
         assert not result.is_error
108108
         assert "Modified Line 2" in sample_file.read_text()
109109
 
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
+
110121
     @pytest.mark.asyncio
111122
     async def test_edit_nonexistent(self, tool, temp_dir):
112123
         result = await tool.execute(