Anchor stale HTML repairs
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
5e2425f67fcc34e23aeaac86454abb76de2af51a- Parents
-
6236b4d - Tree
2b388e9
5e2425f
5e2425f67fcc34e23aeaac86454abb76de2af51a6236b4d
2b388e9| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/repair.py
|
24 | 2 |
| M |
src/loader/runtime/repair_focus.py
|
32 | 0 |
| M |
src/loader/runtime/tool_batches.py
|
32 | 2 |
| M |
src/loader/runtime/turn_completion.py
|
23 | 0 |
| M |
tests/test_repair.py
|
8 | 3 |
| M |
tests/test_tool_batches.py
|
6 | 4 |
| M |
tests/test_turn_completion.py
|
5 | 3 |
src/loader/runtime/repair.pymodified@@ -23,6 +23,7 @@ from .recovery import detect_missing_mutation_payload | |||
| 23 | from .repair_focus import ( | 23 | from .repair_focus import ( |
| 24 | ActiveRepairContext, | 24 | ActiveRepairContext, |
| 25 | extract_active_repair_context, | 25 | extract_active_repair_context, |
| 26 | + html_quality_repair_insertion_anchor, | ||
| 26 | html_repair_issue_is_structural, | 27 | html_repair_issue_is_structural, |
| 27 | recent_repair_mutation_context_failed, | 28 | recent_repair_mutation_context_failed, |
| 28 | repair_line_is_html_quality, | 29 | repair_line_is_html_quality, |
@@ -877,11 +878,32 @@ class ResponseRepairer: | |||
| 877 | if issue_line: | 878 | if issue_line: |
| 878 | lines.append(f"- Current verifier issue: {issue_line[2:] if issue_line.startswith('- ') else issue_line}") | 879 | lines.append(f"- Current verifier issue: {issue_line[2:] if issue_line.startswith('- ') else issue_line}") |
| 879 | structural_issue = html_repair_issue_is_structural(issue_line) | 880 | structural_issue = html_repair_issue_is_structural(issue_line) |
| 880 | - force_write = structural_issue or recent_repair_mutation_context_failed( | 881 | + stale_context = recent_repair_mutation_context_failed( |
| 881 | self.context.session.messages, | 882 | self.context.session.messages, |
| 882 | target, | 883 | target, |
| 883 | ) | 884 | ) |
| 884 | - if force_write: | 885 | + insertion_anchor = ( |
| 886 | + html_quality_repair_insertion_anchor(target) | ||
| 887 | + if stale_context and not structural_issue | ||
| 888 | + else None | ||
| 889 | + ) | ||
| 890 | + if insertion_anchor: | ||
| 891 | + lines.extend( | ||
| 892 | + [ | ||
| 893 | + "- Recent `edit`/`patch` attempts for this same target failed " | ||
| 894 | + "against stale or malformed context, but the current file has a " | ||
| 895 | + "safe closing-tail insertion anchor. Use exactly one " | ||
| 896 | + "`edit(file_path=..., old_string=..., new_string=...)` call now.", | ||
| 897 | + "- Use this exact `old_string` value from the current file:\n" | ||
| 898 | + f"```html\n{insertion_anchor}\n```", | ||
| 899 | + "- Set `new_string` to the substantive new body sections followed " | ||
| 900 | + "by that exact `old_string`, so the added content lands before the " | ||
| 901 | + "closing body/html tags.", | ||
| 902 | + "- Do not call `read`, `patch`, `write`, TodoWrite, or a final " | ||
| 903 | + "summary on this retry; emit the `edit` mutation tool call now.", | ||
| 904 | + ] | ||
| 905 | + ) | ||
| 906 | + elif structural_issue or stale_context: | ||
| 885 | structural_suffix = ( | 907 | structural_suffix = ( |
| 886 | " Ensure the replacement has exactly one closing `</body>` tag, " | 908 | " Ensure the replacement has exactly one closing `</body>` tag, " |
| 887 | "exactly one closing `</html>` tag, and no content after `</html>`." | 909 | "exactly one closing `</html>` tag, and no content after `</html>`." |
src/loader/runtime/repair_focus.pymodified@@ -41,6 +41,8 @@ _HTML_STRUCTURAL_REPAIR_MARKERS = ( | |||
| 41 | "content appears after closing </html>", | 41 | "content appears after closing </html>", |
| 42 | "closing </body> appears after closing </html>", | 42 | "closing </body> appears after closing </html>", |
| 43 | ) | 43 | ) |
| 44 | +_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE) | ||
| 45 | +_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE) | ||
| 44 | 46 | ||
| 45 | 47 | ||
| 46 | @dataclass(frozen=True) | 48 | @dataclass(frozen=True) |
@@ -193,6 +195,36 @@ def html_repair_issue_is_structural(line: str) -> bool: | |||
| 193 | return any(marker in lowered for marker in _HTML_STRUCTURAL_REPAIR_MARKERS) | 195 | return any(marker in lowered for marker in _HTML_STRUCTURAL_REPAIR_MARKERS) |
| 194 | 196 | ||
| 195 | 197 | ||
| 198 | +def html_quality_repair_insertion_anchor(raw_path: str) -> str | None: | ||
| 199 | + """Return an exact on-disk closing-tail anchor for bounded HTML expansion.""" | ||
| 200 | + | ||
| 201 | + normalized = normalize_repair_path(raw_path) | ||
| 202 | + if not normalized: | ||
| 203 | + return None | ||
| 204 | + path = Path(normalized) | ||
| 205 | + if not path.is_file(): | ||
| 206 | + return None | ||
| 207 | + try: | ||
| 208 | + text = path.read_text() | ||
| 209 | + except (OSError, UnicodeDecodeError): | ||
| 210 | + return None | ||
| 211 | + | ||
| 212 | + body_matches = list(_BODY_CLOSE_RE.finditer(text)) | ||
| 213 | + html_matches = list(_HTML_CLOSE_RE.finditer(text)) | ||
| 214 | + if len(body_matches) != 1 or len(html_matches) != 1: | ||
| 215 | + return None | ||
| 216 | + body_match = body_matches[0] | ||
| 217 | + html_match = html_matches[0] | ||
| 218 | + if body_match.start() > html_match.start(): | ||
| 219 | + return None | ||
| 220 | + if text[html_match.end() :].strip(): | ||
| 221 | + return None | ||
| 222 | + anchor = text[body_match.start() :].rstrip() | ||
| 223 | + if not anchor.strip(): | ||
| 224 | + return None | ||
| 225 | + return anchor | ||
| 226 | + | ||
| 227 | + | ||
| 196 | def normalize_repair_path(raw_path: str) -> str: | 228 | def normalize_repair_path(raw_path: str) -> str: |
| 197 | text = str(raw_path or "").strip() | 229 | text = str(raw_path or "").strip() |
| 198 | if not text: | 230 | if not text: |
src/loader/runtime/tool_batches.pymodified@@ -36,6 +36,7 @@ from .policy_timeline import append_verification_timeline_entry | |||
| 36 | from .recovery import RecoveryContext, detect_missing_mutation_payload | 36 | from .recovery import RecoveryContext, detect_missing_mutation_payload |
| 37 | from .repair_focus import ( | 37 | from .repair_focus import ( |
| 38 | extract_active_repair_context, | 38 | extract_active_repair_context, |
| 39 | + html_quality_repair_insertion_anchor, | ||
| 39 | html_repair_issue_is_structural, | 40 | html_repair_issue_is_structural, |
| 40 | path_within_allowed_roots, | 41 | path_within_allowed_roots, |
| 41 | recent_repair_mutation_context_failed, | 42 | recent_repair_mutation_context_failed, |
@@ -606,6 +607,19 @@ class ToolBatchRunner: | |||
| 606 | self.context.recovery_context, | 607 | self.context.recovery_context, |
| 607 | ) | 608 | ) |
| 608 | if edit_mismatch_target and _tool_call_targets_path(tool_call, edit_mismatch_target): | 609 | if edit_mismatch_target and _tool_call_targets_path(tool_call, edit_mismatch_target): |
| 610 | + insertion_anchor = html_quality_repair_insertion_anchor(edit_mismatch_target) | ||
| 611 | + if insertion_anchor: | ||
| 612 | + self.context.queue_steering_message( | ||
| 613 | + "Reuse the earlier observation instead of repeating it. " | ||
| 614 | + f"The last edit on `{edit_mismatch_target}` failed because `old_string` " | ||
| 615 | + "did not exactly match the current file. Use this exact current " | ||
| 616 | + "closing-tail anchor as `old_string` in one concrete `edit` call:\n" | ||
| 617 | + f"```html\n{insertion_anchor}\n```\n" | ||
| 618 | + "Set `new_string` to the substantive added body sections followed " | ||
| 619 | + "by that exact `old_string`, so the new content lands before the " | ||
| 620 | + "closing body/html tags. Do not read the same file again first." | ||
| 621 | + ) | ||
| 622 | + return | ||
| 609 | self.context.queue_steering_message( | 623 | self.context.queue_steering_message( |
| 610 | "Reuse the earlier observation instead of repeating it. " | 624 | "Reuse the earlier observation instead of repeating it. " |
| 611 | f"The last edit on `{edit_mismatch_target}` failed because `old_string` " | 625 | f"The last edit on `{edit_mismatch_target}` failed because `old_string` " |
@@ -2045,11 +2059,27 @@ class ToolBatchRunner: | |||
| 2045 | else f"- Improve `{target}` until it satisfies the active content-quality verifier.\n" | 2059 | else f"- Improve `{target}` until it satisfies the active content-quality verifier.\n" |
| 2046 | ) | 2060 | ) |
| 2047 | structural_repair = html_repair_issue_is_structural(repair_issue) | 2061 | structural_repair = html_repair_issue_is_structural(repair_issue) |
| 2048 | - force_write = recent_repair_mutation_context_failed( | 2062 | + stale_context = recent_repair_mutation_context_failed( |
| 2049 | self.context.session.messages, | 2063 | self.context.session.messages, |
| 2050 | target, | 2064 | target, |
| 2051 | ) | 2065 | ) |
| 2052 | - if force_write or structural_repair: | 2066 | + insertion_anchor = ( |
| 2067 | + html_quality_repair_insertion_anchor(target) | ||
| 2068 | + if stale_context and not structural_repair | ||
| 2069 | + else None | ||
| 2070 | + ) | ||
| 2071 | + if insertion_anchor: | ||
| 2072 | + immediate_step = ( | ||
| 2073 | + f"- Immediate next step: edit `{target}`.\n" | ||
| 2074 | + "- Use one `edit(file_path=..., old_string=..., new_string=...)` " | ||
| 2075 | + "call anchored on the current closing document tail.\n" | ||
| 2076 | + "- Use this exact current closing-tail anchor as `old_string`:\n" | ||
| 2077 | + f"```html\n{insertion_anchor}\n```\n" | ||
| 2078 | + "- Set `new_string` to the substantive added body sections followed " | ||
| 2079 | + "by that exact `old_string`; do not call `read`, `patch`, `write`, " | ||
| 2080 | + "or TodoWrite again first." | ||
| 2081 | + ) | ||
| 2082 | + elif stale_context or structural_repair: | ||
| 2053 | structural_suffix = ( | 2083 | structural_suffix = ( |
| 2054 | " Ensure the replacement has exactly one closing `</body>` tag, " | 2084 | " Ensure the replacement has exactly one closing `</body>` tag, " |
| 2055 | "exactly one closing `</html>` tag, and no content after `</html>`." | 2085 | "exactly one closing `</html>` tag, and no content after `</html>`." |
src/loader/runtime/turn_completion.pymodified@@ -29,6 +29,7 @@ from .policy_timeline import ( | |||
| 29 | from .repair import ResponseRepairer | 29 | from .repair import ResponseRepairer |
| 30 | from .repair_focus import ( | 30 | from .repair_focus import ( |
| 31 | extract_active_repair_context, | 31 | extract_active_repair_context, |
| 32 | + html_quality_repair_insertion_anchor, | ||
| 32 | html_repair_issue_is_structural, | 33 | html_repair_issue_is_structural, |
| 33 | recent_repair_mutation_context_failed, | 34 | recent_repair_mutation_context_failed, |
| 34 | repair_line_is_html_quality, | 35 | repair_line_is_html_quality, |
@@ -549,6 +550,28 @@ def _build_html_quality_repair_continuation( | |||
| 549 | ) | 550 | ) |
| 550 | issue_sentence = f" Current verifier issue: {issue_line}" if issue_line else "" | 551 | issue_sentence = f" Current verifier issue: {issue_line}" if issue_line else "" |
| 551 | structural_issue = html_repair_issue_is_structural(issue_line) | 552 | structural_issue = html_repair_issue_is_structural(issue_line) |
| 553 | + insertion_anchor = ( | ||
| 554 | + html_quality_repair_insertion_anchor(target_text) | ||
| 555 | + if stale_context and not structural_issue | ||
| 556 | + else None | ||
| 557 | + ) | ||
| 558 | + if insertion_anchor: | ||
| 559 | + prompt = ( | ||
| 560 | + "[CONTINUE QUALITY REPAIR]\n" | ||
| 561 | + "You just described a content-quality repair, but did not execute it. " | ||
| 562 | + "Recent `patch`/`edit` attempts for this same file failed because their " | ||
| 563 | + "remembered context was stale or malformed, so anchor the mutation on the " | ||
| 564 | + "current closing document tail. " | ||
| 565 | + f"Emit exactly one `edit(file_path=..., old_string=..., new_string=...)` tool call for `{target_text}` now." | ||
| 566 | + f"{issue_sentence} " | ||
| 567 | + "Use this exact `old_string` value from the current file:\n" | ||
| 568 | + f"```html\n{insertion_anchor}\n```\n" | ||
| 569 | + "Set `new_string` to the substantive new body sections followed by that exact " | ||
| 570 | + "`old_string`, so content is inserted before the closing body/html tags. " | ||
| 571 | + "Do not call `read`, `patch`, `write`, `TodoWrite`, or summarize." | ||
| 572 | + ) | ||
| 573 | + return InProgressContinuation(prompt=prompt, target=None) | ||
| 574 | + | ||
| 552 | force_write = stale_context or structural_issue | 575 | force_write = stale_context or structural_issue |
| 553 | if force_write: | 576 | if force_write: |
| 554 | structural_sentence = ( | 577 | structural_sentence = ( |
tests/test_repair.pymodified@@ -321,7 +321,7 @@ def test_empty_response_retry_during_html_quality_repair_shrinks_mutation( | |||
| 321 | assert f"`{second_chapter.resolve(strict=False)}`" in decision.retry_message | 321 | assert f"`{second_chapter.resolve(strict=False)}`" in decision.retry_message |
| 322 | 322 | ||
| 323 | 323 | ||
| 324 | -def test_empty_response_retry_forces_write_after_stale_quality_repair_context( | 324 | +def test_empty_response_retry_uses_exact_anchor_after_stale_quality_repair_context( |
| 325 | temp_dir: Path, | 325 | temp_dir: Path, |
| 326 | ) -> None: | 326 | ) -> None: |
| 327 | context = build_context( | 327 | context = build_context( |
@@ -367,8 +367,13 @@ def test_empty_response_retry_forces_write_after_stale_quality_repair_context( | |||
| 367 | 367 | ||
| 368 | assert decision.should_continue is True | 368 | assert decision.should_continue is True |
| 369 | assert decision.retry_message is not None | 369 | assert decision.retry_message is not None |
| 370 | - assert "Use exactly one `write(file_path=..., content=...)`" in decision.retry_message | 370 | + assert ( |
| 371 | - assert "Do not call `read`, `edit`, `patch`, TodoWrite" in decision.retry_message | 371 | + "Use exactly one `edit(file_path=..., old_string=..., new_string=...)`" |
| 372 | + in decision.retry_message | ||
| 373 | + ) | ||
| 374 | + assert "Use this exact `old_string` value from the current file" in decision.retry_message | ||
| 375 | + assert "```html\n</body></html>\n```" in decision.retry_message | ||
| 376 | + assert "Do not call `read`, `patch`, `write`, TodoWrite" in decision.retry_message | ||
| 372 | 377 | ||
| 373 | 378 | ||
| 374 | def test_empty_response_retry_forces_write_for_structural_html_repair( | 379 | def test_empty_response_retry_forces_write_for_structural_html_repair( |
tests/test_tool_batches.pymodified@@ -4425,7 +4425,7 @@ async def test_tool_batch_runner_todowrite_during_quality_repair_requires_mutati | |||
| 4425 | assert dod.completed_items == completed_before_todowrite | 4425 | assert dod.completed_items == completed_before_todowrite |
| 4426 | 4426 | ||
| 4427 | 4427 | ||
| 4428 | -def test_todowrite_quality_repair_nudge_forces_write_after_stale_context( | 4428 | +def test_todowrite_quality_repair_nudge_uses_exact_anchor_after_stale_context( |
| 4429 | temp_dir: Path, | 4429 | temp_dir: Path, |
| 4430 | ) -> None: | 4430 | ) -> None: |
| 4431 | async def assess_confidence( | 4431 | async def assess_confidence( |
@@ -4482,9 +4482,11 @@ def test_todowrite_quality_repair_nudge_forces_write_after_stale_context( | |||
| 4482 | 4482 | ||
| 4483 | assert queued_messages | 4483 | assert queued_messages |
| 4484 | message = queued_messages[-1] | 4484 | message = queued_messages[-1] |
| 4485 | - assert f"Immediate next step: rewrite `{chapter_one.resolve(strict=False)}`" in message | 4485 | + assert f"Immediate next step: edit `{chapter_one.resolve(strict=False)}`" in message |
| 4486 | - assert "`write(file_path=..., content=...)`" in message | 4486 | + assert "`edit(file_path=..., old_string=..., new_string=...)`" in message |
| 4487 | - assert "do not call `read`, `edit`, `patch`, or TodoWrite again first" in message | 4487 | + assert "Use this exact current closing-tail anchor as `old_string`" in message |
| 4488 | + assert "```html\n</body></html>\n```" in message | ||
| 4489 | + assert "do not call `read`, `patch`, `write`, or TodoWrite again first" in message | ||
| 4488 | 4490 | ||
| 4489 | 4491 | ||
| 4490 | @pytest.mark.asyncio | 4492 | @pytest.mark.asyncio |
tests/test_turn_completion.pymodified@@ -456,7 +456,7 @@ async def test_turn_completion_uses_quality_repair_prompt_for_rewrite_narration( | |||
| 456 | 456 | ||
| 457 | 457 | ||
| 458 | @pytest.mark.asyncio | 458 | @pytest.mark.asyncio |
| 459 | -async def test_turn_completion_forces_write_after_stale_quality_repair_context( | 459 | +async def test_turn_completion_uses_exact_anchor_after_stale_quality_repair_context( |
| 460 | temp_dir: Path, | 460 | temp_dir: Path, |
| 461 | ) -> None: | 461 | ) -> None: |
| 462 | backend = ScriptedBackend() | 462 | backend = ScriptedBackend() |
@@ -533,8 +533,10 @@ async def test_turn_completion_forces_write_after_stale_quality_repair_context( | |||
| 533 | assert decision.action == TurnCompletionAction.CONTINUE | 533 | assert decision.action == TurnCompletionAction.CONTINUE |
| 534 | message = agent.session.messages[-1].content | 534 | message = agent.session.messages[-1].content |
| 535 | assert message.startswith("[CONTINUE QUALITY REPAIR]") | 535 | assert message.startswith("[CONTINUE QUALITY REPAIR]") |
| 536 | - assert "exactly one `write(file_path=..., content=...)`" in message | 536 | + assert "exactly one `edit(file_path=..., old_string=..., new_string=...)`" in message |
| 537 | - assert "Do not call `read`, `edit`, `patch`, `TodoWrite`, or summarize." in message | 537 | + assert "Use this exact `old_string` value from the current file" in message |
| 538 | + assert "```html\n</body></html>\n```" in message | ||
| 539 | + assert "Do not call `read`, `patch`, `write`, `TodoWrite`, or summarize." in message | ||
| 538 | 540 | ||
| 539 | 541 | ||
| 540 | @pytest.mark.asyncio | 542 | @pytest.mark.asyncio |