Tighten payload-free mutation recovery
- SHA
09f576cf02b4803d672efa5307ec1838884b3c16- Parents
-
dcbd31b - Tree
05b0f18
09f576c
09f576cf02b4803d672efa5307ec1838884b3c16dcbd31b
05b0f18| Status | File | + | - |
|---|---|---|---|
| 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: | ||
| 523 | 523 | ): |
| 524 | 524 | return ErrorCategory.INVALID_ARGUMENTS |
| 525 | 525 | |
| 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 | + | |
| 526 | 537 | if any(token in error_lower for token in ["network", "unreachable", "dns", "getaddrinfo"]): |
| 527 | 538 | return ErrorCategory.NETWORK_ERROR |
| 528 | 539 | |
| 529 | 540 | return ErrorCategory.UNKNOWN |
| 530 | 541 | |
| 531 | 542 | |
| 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 | + | |
| 532 | 610 | def get_recovery_hints( |
| 533 | 611 | category: ErrorCategory, |
| 534 | 612 | tool_name: str, |
@@ -688,6 +766,45 @@ def get_recovery_hints( | ||
| 688 | 766 | "If the exact replacement span is unclear, read just the target file and then edit it", |
| 689 | 767 | ] + category_hints |
| 690 | 768 | |
| 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 | + | |
| 691 | 808 | return "\n".join(f"- {hint}" for hint in category_hints) |
| 692 | 809 | |
| 693 | 810 | |
src/loader/runtime/repair.pymodified@@ -15,6 +15,7 @@ from .dod import ( | ||
| 15 | 15 | planned_artifact_target_satisfied, |
| 16 | 16 | ) |
| 17 | 17 | from .parsing import parse_tool_calls |
| 18 | +from .recovery import detect_missing_mutation_payload | |
| 18 | 19 | from .workflow import ( |
| 19 | 20 | infer_pending_todo_output_target, |
| 20 | 21 | preferred_pending_todo_item, |
@@ -251,6 +252,7 @@ class ResponseRepairer: | ||
| 251 | 252 | if dod is not None and self._should_compact_empty_retry_message(dod): |
| 252 | 253 | compact_lines: list[str] = [] |
| 253 | 254 | compact_lines.extend(self._planned_artifact_progress_lines(dod)[:2]) |
| 255 | + compact_lines.extend(self._payload_retry_lines()) | |
| 254 | 256 | compact_lines.extend( |
| 255 | 257 | self._next_step_resume_lines( |
| 256 | 258 | dod, |
@@ -285,6 +287,7 @@ class ResponseRepairer: | ||
| 285 | 287 | |
| 286 | 288 | planned_lines = self._planned_artifact_progress_lines(dod) |
| 287 | 289 | progress_lines.extend(planned_lines) |
| 290 | + progress_lines.extend(self._payload_retry_lines()) | |
| 288 | 291 | progress_lines.extend( |
| 289 | 292 | self._next_step_resume_lines( |
| 290 | 293 | dod, |
@@ -360,6 +363,51 @@ class ResponseRepairer: | ||
| 360 | 363 | ] |
| 361 | 364 | ) |
| 362 | 365 | |
| 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 | + | |
| 363 | 411 | def _todo_refresh_retry_line(self, dod: DefinitionOfDone) -> str | None: |
| 364 | 412 | non_special_pending = [ |
| 365 | 413 | 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 ( | ||
| 17 | 17 | from .context import RuntimeContext |
| 18 | 18 | from .events import AgentEvent |
| 19 | 19 | 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 | +) | |
| 21 | 26 | from .repair_focus import ActiveRepairContext, extract_active_repair_context |
| 22 | 27 | |
| 23 | 28 | EventSink = Callable[[AgentEvent], Awaitable[None]] |
@@ -233,8 +238,65 @@ class ToolBatchRecoveryController: | ||
| 233 | 238 | target_excerpt_lines = self._target_excerpt_lines(tool_call) |
| 234 | 239 | if target_excerpt_lines: |
| 235 | 240 | 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]) | |
| 236 | 244 | return "\n".join(lines) |
| 237 | 245 | |
| 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 | + | |
| 238 | 300 | def _preferred_focus_path( |
| 239 | 301 | self, |
| 240 | 302 | *, |
tests/test_recovery.pymodified@@ -38,6 +38,12 @@ class TestCategorizeError: | ||
| 38 | 38 | def test_invalid_arguments(self): |
| 39 | 39 | assert categorize_error("Invalid argument: path") == ErrorCategory.INVALID_ARGUMENTS |
| 40 | 40 | 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 | + ) | |
| 41 | 47 | |
| 42 | 48 | def test_network_error(self): |
| 43 | 49 | assert categorize_error("Network unreachable") == ErrorCategory.NETWORK_ERROR |
@@ -131,6 +137,20 @@ class TestGetRecoveryHints: | ||
| 131 | 137 | assert "edit/patch/write" in hints.lower() |
| 132 | 138 | assert "index.html" in hints |
| 133 | 139 | |
| 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 | + | |
| 134 | 154 | |
| 135 | 155 | class TestFormatRecoveryPrompt: |
| 136 | 156 | """Tests for recovery prompt formatting.""" |
@@ -170,6 +190,40 @@ class TestFormatRecoveryPrompt: | ||
| 170 | 190 | assert "edit/patch/write" in prompt.lower() |
| 171 | 191 | assert "index.html" in prompt |
| 172 | 192 | |
| 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 | + | |
| 173 | 227 | |
| 174 | 228 | class TestFormatFailureMessage: |
| 175 | 229 | """Tests for failure message formatting.""" |
tests/test_repair.pymodified@@ -14,6 +14,7 @@ from loader.runtime.permissions import ( | ||
| 14 | 14 | build_permission_policy, |
| 15 | 15 | load_permission_rules, |
| 16 | 16 | ) |
| 17 | +from loader.runtime.recovery import RecoveryContext | |
| 17 | 18 | from loader.runtime.repair import ResponseRepairer |
| 18 | 19 | from loader.tools.base import create_default_registry |
| 19 | 20 | from tests.helpers.runtime_harness import ScriptedBackend |
@@ -965,6 +966,77 @@ def test_empty_response_retry_maps_title_style_todo_to_html_graph_target( | ||
| 965 | 966 | ) |
| 966 | 967 | |
| 967 | 968 | |
| 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 | + | |
| 968 | 1040 | def test_empty_response_retry_uses_compact_prompt_after_early_progress_with_concrete_next_file( |
| 969 | 1041 | temp_dir: Path, |
| 970 | 1042 | ) -> None: |
tests/test_tool_batch_policies.pymodified@@ -883,6 +883,69 @@ async def test_tool_batch_recovery_controller_uses_generic_loop_guidance( | ||
| 883 | 883 | assert "verify the current result" in error_event.content |
| 884 | 884 | |
| 885 | 885 | |
| 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 | + | |
| 886 | 949 | @pytest.mark.asyncio |
| 887 | 950 | async def test_tool_batch_recovery_controller_resets_context_for_unrelated_failures( |
| 888 | 951 | temp_dir: Path, |