tenseleyflow/loader / 09f576c

Browse files

Tighten payload-free mutation recovery

Authored by espadonne
SHA
09f576cf02b4803d672efa5307ec1838884b3c16
Parents
dcbd31b
Tree
05b0f18

6 changed files

StatusFile+-
M src/loader/runtime/recovery.py 117 0
M src/loader/runtime/repair.py 48 0
M src/loader/runtime/tool_batch_recovery.py 63 1
M tests/test_recovery.py 54 0
M tests/test_repair.py 72 0
M tests/test_tool_batch_policies.py 63 0
src/loader/runtime/recovery.pymodified
@@ -523,12 +523,90 @@ def categorize_error(error_message: str) -> ErrorCategory:
523523
     ):
524524
         return ErrorCategory.INVALID_ARGUMENTS
525525
 
526
+    if any(
527
+        token in error_lower
528
+        for token in [
529
+            "required positional argument",
530
+            "missing 1 required",
531
+            "missing required positional",
532
+            "empty content",
533
+        ]
534
+    ):
535
+        return ErrorCategory.INVALID_ARGUMENTS
536
+
526537
     if any(token in error_lower for token in ["network", "unreachable", "dns", "getaddrinfo"]):
527538
         return ErrorCategory.NETWORK_ERROR
528539
 
529540
     return ErrorCategory.UNKNOWN
530541
 
531542
 
543
+def detect_missing_mutation_payload(
544
+    tool_name: str,
545
+    args: dict[str, Any] | None,
546
+    error: str,
547
+) -> dict[str, Any] | None:
548
+    """Detect metadata-only mutation calls missing their real text payload."""
549
+
550
+    arguments = dict(args or {})
551
+    error_lower = error.lower()
552
+    if error and not any(
553
+        token in error_lower
554
+        for token in [
555
+            "required positional argument",
556
+            "missing 1 required",
557
+            "missing required",
558
+            "empty content",
559
+            "validation warning",
560
+        ]
561
+    ):
562
+        return None
563
+
564
+    file_path = str(arguments.get("file_path") or arguments.get("path") or "").strip()
565
+
566
+    if tool_name == "write":
567
+        invalid_fields = [
568
+            field for field in ("content_chars", "content_lines") if field in arguments
569
+        ]
570
+        if "content" not in arguments and invalid_fields:
571
+            return {
572
+                "required_fields": ["content"],
573
+                "invalid_fields": invalid_fields,
574
+                "file_path": file_path,
575
+            }
576
+
577
+    if tool_name == "edit":
578
+        missing_fields = [
579
+            field for field in ("old_string", "new_string") if field not in arguments
580
+        ]
581
+        invalid_fields = [
582
+            field
583
+            for field in (
584
+                "old_string_chars",
585
+                "old_string_lines",
586
+                "new_string_chars",
587
+                "new_string_lines",
588
+            )
589
+            if field in arguments
590
+        ]
591
+        if missing_fields and invalid_fields:
592
+            return {
593
+                "required_fields": missing_fields,
594
+                "invalid_fields": invalid_fields,
595
+                "file_path": file_path,
596
+            }
597
+
598
+    if tool_name == "patch":
599
+        invalid_fields = [field for field in ("hunk_count",) if field in arguments]
600
+        if "patch" not in arguments and "hunks" not in arguments and invalid_fields:
601
+            return {
602
+                "required_fields": ["patch or hunks"],
603
+                "invalid_fields": invalid_fields,
604
+                "file_path": file_path,
605
+            }
606
+
607
+    return None
608
+
609
+
532610
 def get_recovery_hints(
533611
     category: ErrorCategory,
534612
     tool_name: str,
@@ -688,6 +766,45 @@ def get_recovery_hints(
688766
             "If the exact replacement span is unclear, read just the target file and then edit it",
689767
         ] + category_hints
690768
 
769
+    payload_fix = detect_missing_mutation_payload(tool_name, args, "")
770
+    if payload_fix is not None:
771
+        required = ", ".join(payload_fix["required_fields"])
772
+        invalid = ", ".join(payload_fix["invalid_fields"])
773
+        target = payload_fix["file_path"]
774
+        if tool_name == "write":
775
+            category_hints = [
776
+                (
777
+                    f"Resend the mutation as `write(file_path=..., content='...')` "
778
+                    f"for `{target}` with the real file body"
779
+                    if target
780
+                    else "Resend the mutation as `write(file_path=..., content='...')` with the real file body"
781
+                ),
782
+                (
783
+                    f"`{invalid}` are summary fields, not valid write inputs; provide `{required}` instead"
784
+                ),
785
+                "Do not reread reference files first unless one specific fact still blocks the write",
786
+            ]
787
+        elif tool_name == "edit":
788
+            category_hints = [
789
+                (
790
+                    f"Resend the mutation for `{target}` with the real `{required}` text payload"
791
+                    if target
792
+                    else f"Resend the mutation with the real `{required}` text payload"
793
+                ),
794
+                f"`{invalid}` are summary fields, not valid edit inputs; provide `{required}` instead",
795
+                "Do not reread reference files first unless one specific exact replacement span is still unknown",
796
+            ]
797
+        elif tool_name == "patch":
798
+            category_hints = [
799
+                (
800
+                    f"Resend the mutation for `{target}` with real `patch` text or structured `hunks`"
801
+                    if target
802
+                    else "Resend the mutation with real `patch` text or structured `hunks`"
803
+                ),
804
+                f"`{invalid}` are summary fields, not valid patch inputs; provide `{required}` instead",
805
+                "Do not reread reference files first unless one specific edit span is still unknown",
806
+            ]
807
+
691808
     return "\n".join(f"- {hint}" for hint in category_hints)
692809
 
693810
 
src/loader/runtime/repair.pymodified
@@ -15,6 +15,7 @@ from .dod import (
1515
     planned_artifact_target_satisfied,
1616
 )
1717
 from .parsing import parse_tool_calls
18
+from .recovery import detect_missing_mutation_payload
1819
 from .workflow import (
1920
     infer_pending_todo_output_target,
2021
     preferred_pending_todo_item,
@@ -251,6 +252,7 @@ class ResponseRepairer:
251252
         if dod is not None and self._should_compact_empty_retry_message(dod):
252253
             compact_lines: list[str] = []
253254
             compact_lines.extend(self._planned_artifact_progress_lines(dod)[:2])
255
+            compact_lines.extend(self._payload_retry_lines())
254256
             compact_lines.extend(
255257
                 self._next_step_resume_lines(
256258
                     dod,
@@ -285,6 +287,7 @@ class ResponseRepairer:
285287
 
286288
             planned_lines = self._planned_artifact_progress_lines(dod)
287289
             progress_lines.extend(planned_lines)
290
+            progress_lines.extend(self._payload_retry_lines())
288291
             progress_lines.extend(
289292
                 self._next_step_resume_lines(
290293
                     dod,
@@ -360,6 +363,51 @@ class ResponseRepairer:
360363
             ]
361364
         )
362365
 
366
+    def _payload_retry_lines(self) -> list[str]:
367
+        recovery_context = self.context.recovery_context
368
+        if recovery_context is None or not recovery_context.attempts:
369
+            return []
370
+        attempt = recovery_context.attempts[-1]
371
+        fix = detect_missing_mutation_payload(
372
+            attempt.tool_name,
373
+            attempt.arguments,
374
+            attempt.error,
375
+        )
376
+        if fix is None:
377
+            return []
378
+
379
+        target = fix["file_path"]
380
+        invalid = ", ".join(f"`{field}`" for field in fix["invalid_fields"])
381
+        if attempt.tool_name == "write":
382
+            target_line = (
383
+                f"Last tool failure: resend `write` for `{target}` with real `content`, not just summary fields."
384
+                if target
385
+                else "Last tool failure: resend `write` with real `content`, not just summary fields."
386
+            )
387
+            return [
388
+                target_line,
389
+                f"Do not use {invalid} in place of the actual file body.",
390
+            ]
391
+        if attempt.tool_name == "edit":
392
+            return [
393
+                (
394
+                    f"Last tool failure: resend `edit` for `{target}` with the real text payload."
395
+                    if target
396
+                    else "Last tool failure: resend `edit` with the real text payload."
397
+                ),
398
+                f"Do not use {invalid} in place of `old_string`/`new_string`.",
399
+            ]
400
+        if attempt.tool_name == "patch":
401
+            return [
402
+                (
403
+                    f"Last tool failure: resend `patch` for `{target}` with real patch text or structured hunks."
404
+                    if target
405
+                    else "Last tool failure: resend `patch` with real patch text or structured hunks."
406
+                ),
407
+                f"Do not use {invalid} in place of the real patch payload.",
408
+            ]
409
+        return []
410
+
363411
     def _todo_refresh_retry_line(self, dod: DefinitionOfDone) -> str | None:
364412
         non_special_pending = [
365413
             item for item in dod.pending_items if item not in _SPECIAL_DOD_ITEMS
src/loader/runtime/tool_batch_recovery.pymodified
@@ -17,7 +17,12 @@ from .compaction import (
1717
 from .context import RuntimeContext
1818
 from .events import AgentEvent
1919
 from .executor import ToolExecutionOutcome
20
-from .recovery import RecoveryContext, format_failure_message, format_recovery_prompt
20
+from .recovery import (
21
+    RecoveryContext,
22
+    detect_missing_mutation_payload,
23
+    format_failure_message,
24
+    format_recovery_prompt,
25
+)
2126
 from .repair_focus import ActiveRepairContext, extract_active_repair_context
2227
 
2328
 EventSink = Callable[[AgentEvent], Awaitable[None]]
@@ -233,8 +238,65 @@ class ToolBatchRecoveryController:
233238
         target_excerpt_lines = self._target_excerpt_lines(tool_call)
234239
         if target_excerpt_lines:
235240
             lines.extend(["", "## CURRENT TARGET EXCERPT", *target_excerpt_lines])
241
+        payload_fix_lines = self._missing_payload_fix_lines(tool_call, outcome)
242
+        if payload_fix_lines:
243
+            lines.extend(["", "## PAYLOAD FORMAT FIX", *payload_fix_lines])
236244
         return "\n".join(lines)
237245
 
246
+    def _missing_payload_fix_lines(
247
+        self,
248
+        tool_call: ToolCall,
249
+        outcome: ToolExecutionOutcome,
250
+    ) -> list[str]:
251
+        fix = detect_missing_mutation_payload(
252
+            tool_call.name,
253
+            tool_call.arguments,
254
+            outcome.result_output,
255
+        )
256
+        if fix is None:
257
+            return []
258
+
259
+        target = fix["file_path"]
260
+        invalid_fields = ", ".join(f"`{field}`" for field in fix["invalid_fields"])
261
+        required_fields = "`, `".join(fix["required_fields"])
262
+        if tool_call.name == "write":
263
+            target_line = (
264
+                f"- The failed call for `{target}` omitted the required `content` payload."
265
+                if target
266
+                else "- The failed call omitted the required `content` payload."
267
+            )
268
+            return [
269
+                target_line,
270
+                f"- {invalid_fields} are summary counters, not valid write inputs.",
271
+                "- Resend one concrete `write(file_path=..., content='...')` call now instead of rereading more files.",
272
+            ]
273
+
274
+        if tool_call.name == "edit":
275
+            target_line = (
276
+                f"- The failed call for `{target}` omitted the required `{required_fields}` payload."
277
+                if target
278
+                else f"- The failed call omitted the required `{required_fields}` payload."
279
+            )
280
+            return [
281
+                target_line,
282
+                f"- {invalid_fields} are summary counters, not valid edit inputs.",
283
+                "- Resend one concrete `edit(file_path=..., old_string='...', new_string='...')` call now instead of rereading more files.",
284
+            ]
285
+
286
+        if tool_call.name == "patch":
287
+            target_line = (
288
+                f"- The failed call for `{target}` omitted the required patch body."
289
+                if target
290
+                else "- The failed call omitted the required patch body."
291
+            )
292
+            return [
293
+                target_line,
294
+                f"- {invalid_fields} are summary counters, not valid patch inputs.",
295
+                "- Resend one concrete `patch(file_path=..., patch='...')` or `patch(..., hunks=[...])` call now instead of rereading more files.",
296
+            ]
297
+
298
+        return []
299
+
238300
     def _preferred_focus_path(
239301
         self,
240302
         *,
tests/test_recovery.pymodified
@@ -38,6 +38,12 @@ class TestCategorizeError:
3838
     def test_invalid_arguments(self):
3939
         assert categorize_error("Invalid argument: path") == ErrorCategory.INVALID_ARGUMENTS
4040
         assert categorize_error("Missing required parameter") == ErrorCategory.INVALID_ARGUMENTS
41
+        assert (
42
+            categorize_error(
43
+                "WriteTool.execute() missing 1 required positional argument: 'content'"
44
+            )
45
+            == ErrorCategory.INVALID_ARGUMENTS
46
+        )
4147
 
4248
     def test_network_error(self):
4349
         assert categorize_error("Network unreachable") == ErrorCategory.NETWORK_ERROR
@@ -131,6 +137,20 @@ class TestGetRecoveryHints:
131137
         assert "edit/patch/write" in hints.lower()
132138
         assert "index.html" in hints
133139
 
140
+    def test_write_metadata_only_hint_requests_real_content_payload(self):
141
+        hints = get_recovery_hints(
142
+            ErrorCategory.INVALID_ARGUMENTS,
143
+            "write",
144
+            {
145
+                "file_path": "~/Loader/guides/nginx/index.html",
146
+                "content_chars": 1354,
147
+                "content_lines": 30,
148
+            },
149
+        )
150
+        assert "content='...'" in hints
151
+        assert "content_chars" in hints
152
+        assert "index.html" in hints
153
+
134154
 
135155
 class TestFormatRecoveryPrompt:
136156
     """Tests for recovery prompt formatting."""
@@ -170,6 +190,40 @@ class TestFormatRecoveryPrompt:
170190
         assert "edit/patch/write" in prompt.lower()
171191
         assert "index.html" in prompt
172192
 
193
+    def test_format_recovery_prompt_for_metadata_only_write_requests_real_payload(self):
194
+        ctx = RecoveryContext(
195
+            original_tool="write",
196
+            original_args={
197
+                "file_path": "~/Loader/guides/nginx/index.html",
198
+                "content_chars": 1354,
199
+                "content_lines": 30,
200
+            },
201
+        )
202
+        ctx.add_attempt(
203
+            "write",
204
+            {
205
+                "file_path": "~/Loader/guides/nginx/index.html",
206
+                "content_chars": 1354,
207
+                "content_lines": 30,
208
+            },
209
+            "WriteTool.execute() missing 1 required positional argument: 'content'",
210
+        )
211
+
212
+        prompt = format_recovery_prompt(
213
+            ctx,
214
+            "write",
215
+            {
216
+                "file_path": "~/Loader/guides/nginx/index.html",
217
+                "content_chars": 1354,
218
+                "content_lines": 30,
219
+            },
220
+            "WriteTool.execute() missing 1 required positional argument: 'content'",
221
+        )
222
+
223
+        assert "content='...'" in prompt
224
+        assert "content_chars" in prompt
225
+        assert "index.html" in prompt
226
+
173227
 
174228
 class TestFormatFailureMessage:
175229
     """Tests for failure message formatting."""
tests/test_repair.pymodified
@@ -14,6 +14,7 @@ from loader.runtime.permissions import (
1414
     build_permission_policy,
1515
     load_permission_rules,
1616
 )
17
+from loader.runtime.recovery import RecoveryContext
1718
 from loader.runtime.repair import ResponseRepairer
1819
 from loader.tools.base import create_default_registry
1920
 from tests.helpers.runtime_harness import ScriptedBackend
@@ -965,6 +966,77 @@ def test_empty_response_retry_maps_title_style_todo_to_html_graph_target(
965966
     )
966967
 
967968
 
969
+def test_empty_response_retry_reminds_model_to_resend_real_write_payload(
970
+    temp_dir: Path,
971
+) -> None:
972
+    context = build_context(
973
+        temp_dir=temp_dir,
974
+        use_react=False,
975
+    )
976
+    repairer = ResponseRepairer(context)
977
+
978
+    guide_root = temp_dir / "guides" / "nginx"
979
+    chapters = guide_root / "chapters"
980
+    chapters.mkdir(parents=True)
981
+    chapter_one = chapters / "01-introduction.html"
982
+    chapter_one.write_text("<html></html>\n")
983
+
984
+    implementation_plan = temp_dir / "implementation.md"
985
+    implementation_plan.write_text(
986
+        "\n".join(
987
+            [
988
+                "# Implementation Plan",
989
+                "",
990
+                "## File Changes",
991
+                f"- `{guide_root}/`",
992
+                f"- `{chapters}/`",
993
+                f"- `{guide_root / 'index.html'}`",
994
+                f"- `{chapters / '01-introduction.html'}`",
995
+                "",
996
+            ]
997
+        )
998
+    )
999
+
1000
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
1001
+    dod.implementation_plan = str(implementation_plan)
1002
+    dod.touched_files.append(str(chapter_one))
1003
+    dod.completed_items.append("Create first chapter file (01-introduction.html)")
1004
+    dod.pending_items.append("Develop the main index.html file for the nginx guide")
1005
+
1006
+    recovery_context = RecoveryContext(
1007
+        original_tool="write",
1008
+        original_args={
1009
+            "file_path": "~/Loader/guides/nginx/index.html",
1010
+            "content_chars": 1354,
1011
+            "content_lines": 30,
1012
+        },
1013
+    )
1014
+    recovery_context.add_attempt(
1015
+        "write",
1016
+        {
1017
+            "file_path": "~/Loader/guides/nginx/index.html",
1018
+            "content_chars": 1354,
1019
+            "content_lines": 30,
1020
+        },
1021
+        "WriteTool.execute() missing 1 required positional argument: 'content'",
1022
+    )
1023
+    context.recovery_context = recovery_context
1024
+
1025
+    decision = repairer.handle_empty_response(
1026
+        task="Create a multi-file nginx guide.",
1027
+        original_task=None,
1028
+        empty_retry_count=2,
1029
+        max_empty_retries=2,
1030
+        dod=dod,
1031
+    )
1032
+
1033
+    assert decision.should_continue is True
1034
+    assert decision.retry_message is not None
1035
+    assert "resend `write`" in decision.retry_message
1036
+    assert "content_chars" in decision.retry_message
1037
+    assert "index.html" in decision.retry_message
1038
+
1039
+
9681040
 def test_empty_response_retry_uses_compact_prompt_after_early_progress_with_concrete_next_file(
9691041
     temp_dir: Path,
9701042
 ) -> None:
tests/test_tool_batch_policies.pymodified
@@ -883,6 +883,69 @@ async def test_tool_batch_recovery_controller_uses_generic_loop_guidance(
883883
     assert "verify the current result" in error_event.content
884884
 
885885
 
886
+@pytest.mark.asyncio
887
+async def test_tool_batch_recovery_controller_surfaces_missing_write_payload_fix(
888
+    temp_dir: Path,
889
+) -> None:
890
+    async def assess_confidence(
891
+        tool_name: str,
892
+        tool_args: dict,
893
+        context: str,
894
+    ) -> ConfidenceAssessment:
895
+        raise AssertionError("Confidence should not run here")
896
+
897
+    async def verify_action(
898
+        tool_name: str,
899
+        tool_args: dict,
900
+        result: str,
901
+        expected: str = "",
902
+    ) -> ActionVerification:
903
+        raise AssertionError("Verification should not run here")
904
+
905
+    context = build_context(
906
+        temp_dir=temp_dir,
907
+        messages=[
908
+            Message(
909
+                role=Role.USER,
910
+                content="Create ~/Loader/guides/nginx/index.html",
911
+            )
912
+        ],
913
+        assess_confidence=assess_confidence,
914
+        verify_action=verify_action,
915
+    )
916
+    controller = ToolBatchRecoveryController(context)
917
+    tool_call = ToolCall(
918
+        id="write-metadata-only",
919
+        name="write",
920
+        arguments={
921
+            "file_path": "~/Loader/guides/nginx/index.html",
922
+            "content_chars": 1354,
923
+            "content_lines": 30,
924
+        },
925
+    )
926
+    outcome = tool_outcome(
927
+        tool_call=tool_call,
928
+        output=(
929
+            "[Validation warning] Writing empty content to file\n"
930
+            "Tool execution error: WriteTool.execute() missing 1 required "
931
+            "positional argument: 'content'"
932
+        ),
933
+        is_error=True,
934
+    )
935
+
936
+    follow_up = await controller.build_follow_up(
937
+        tool_call=tool_call,
938
+        outcome=outcome,
939
+        emit=lambda event: _noop_emit(event),
940
+    )
941
+
942
+    assert follow_up is not None
943
+    assert "## PAYLOAD FORMAT FIX" in follow_up.content
944
+    assert "content_chars" in follow_up.content
945
+    assert "write(file_path=..., content='...')" in follow_up.content
946
+    assert "index.html" in follow_up.content
947
+
948
+
886949
 @pytest.mark.asyncio
887950
 async def test_tool_batch_recovery_controller_resets_context_for_unrelated_failures(
888951
     temp_dir: Path,