Normalize home-rooted path prompts
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
e82c3e2841eee6899eea021a770304dc3566e908- Parents
-
ab475b4 - Tree
475a1d4
e82c3e2
e82c3e2841eee6899eea021a770304dc3566e908ab475b4
475a1d4| Status | File | + | - |
|---|---|---|---|
| A |
src/loader/runtime/path_display.py
|
25 | 0 |
| M |
src/loader/runtime/repair.py
|
30 | 21 |
| M |
src/loader/runtime/tool_batches.py
|
11 | 8 |
| M |
tests/test_repair.py
|
75 | 12 |
| M |
tests/test_tool_batches.py
|
109 | 2 |
src/loader/runtime/path_display.pyadded@@ -0,0 +1,25 @@ | ||
| 1 | +"""Helpers for consistent user-facing runtime path rendering.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | + | |
| 8 | +def display_runtime_path(path_value: str | Path) -> str: | |
| 9 | + """Render a stable user-facing path for prompts and steering messages. | |
| 10 | + | |
| 11 | + Paths under the active HOME are shown as `~/...` to avoid leaking | |
| 12 | + platform-specific symlink expansions such as `/private/tmp/...`. | |
| 13 | + Other paths keep the current normalized absolute rendering. | |
| 14 | + """ | |
| 15 | + | |
| 16 | + path = Path(path_value).expanduser() | |
| 17 | + resolved = path.resolve(strict=False) | |
| 18 | + home = Path.home().expanduser().resolve(strict=False) | |
| 19 | + try: | |
| 20 | + relative = resolved.relative_to(home) | |
| 21 | + except ValueError: | |
| 22 | + return str(resolved) | |
| 23 | + if not relative.parts: | |
| 24 | + return "~" | |
| 25 | + return f"~/{relative.as_posix()}" | |
src/loader/runtime/repair.pymodified@@ -17,6 +17,7 @@ from .dod import ( | ||
| 17 | 17 | planned_artifact_target_satisfied, |
| 18 | 18 | ) |
| 19 | 19 | from .parsing import parse_tool_calls |
| 20 | +from .path_display import display_runtime_path | |
| 20 | 21 | from .recovery import detect_missing_mutation_payload |
| 21 | 22 | from .workflow import ( |
| 22 | 23 | infer_output_outline_label, |
@@ -369,11 +370,12 @@ class ResponseRepairer: | ||
| 369 | 370 | |
| 370 | 371 | target = fix["file_path"] or self._preferred_retry_target(dod) |
| 371 | 372 | invalid = ", ".join(f"`{field}`" for field in fix["invalid_fields"]) |
| 373 | + display_target = display_runtime_path(target) if target else None | |
| 372 | 374 | if fix.get("kind") == "missing_target": |
| 373 | 375 | if attempt.tool_name == "write": |
| 374 | 376 | target_line = ( |
| 375 | - f"Last tool failure: resend `write` for `{target}` with a valid `file_path` and real `content`." | |
| 376 | - if target | |
| 377 | + f"Last tool failure: resend `write` for `{display_target}` with a valid `file_path` and real `content`." | |
| 378 | + if display_target | |
| 377 | 379 | else "Last tool failure: resend `write` with a valid `file_path` and real `content`." |
| 378 | 380 | ) |
| 379 | 381 | return [ |
@@ -388,8 +390,8 @@ class ResponseRepairer: | ||
| 388 | 390 | ] |
| 389 | 391 | if attempt.tool_name == "edit": |
| 390 | 392 | target_line = ( |
| 391 | - f"Last tool failure: resend `edit` for `{target}` with a valid `file_path` plus real `old_string`/`new_string`." | |
| 392 | - if target | |
| 393 | + f"Last tool failure: resend `edit` for `{display_target}` with a valid `file_path` plus real `old_string`/`new_string`." | |
| 394 | + if display_target | |
| 393 | 395 | else "Last tool failure: resend `edit` with a valid `file_path` plus real `old_string`/`new_string`." |
| 394 | 396 | ) |
| 395 | 397 | return [ |
@@ -404,8 +406,8 @@ class ResponseRepairer: | ||
| 404 | 406 | ] |
| 405 | 407 | if attempt.tool_name == "patch": |
| 406 | 408 | target_line = ( |
| 407 | - f"Last tool failure: resend `patch` for `{target}` with a valid `file_path` and real patch text or `hunks`." | |
| 408 | - if target | |
| 409 | + f"Last tool failure: resend `patch` for `{display_target}` with a valid `file_path` and real patch text or `hunks`." | |
| 410 | + if display_target | |
| 409 | 411 | else "Last tool failure: resend `patch` with a valid `file_path` and real patch text or `hunks`." |
| 410 | 412 | ) |
| 411 | 413 | return [ |
@@ -421,8 +423,8 @@ class ResponseRepairer: | ||
| 421 | 423 | if attempt.tool_name == "write": |
| 422 | 424 | lines = [ |
| 423 | 425 | ( |
| 424 | - f"Last tool failure: resend `write` for `{target}` with real `content`, not just summary fields." | |
| 425 | - if target | |
| 426 | + f"Last tool failure: resend `write` for `{display_target}` with real `content`, not just summary fields." | |
| 427 | + if display_target | |
| 426 | 428 | else "Last tool failure: resend `write` with real `content`, not just summary fields." |
| 427 | 429 | ), |
| 428 | 430 | ] |
@@ -438,8 +440,8 @@ class ResponseRepairer: | ||
| 438 | 440 | if attempt.tool_name == "edit": |
| 439 | 441 | lines = [ |
| 440 | 442 | ( |
| 441 | - f"Last tool failure: resend `edit` for `{target}` with the real text payload." | |
| 442 | - if target | |
| 443 | + f"Last tool failure: resend `edit` for `{display_target}` with the real text payload." | |
| 444 | + if display_target | |
| 443 | 445 | else "Last tool failure: resend `edit` with the real text payload." |
| 444 | 446 | ), |
| 445 | 447 | f"Do not use {invalid} in place of `old_string`/`new_string`.", |
@@ -455,8 +457,8 @@ class ResponseRepairer: | ||
| 455 | 457 | if attempt.tool_name == "patch": |
| 456 | 458 | lines = [ |
| 457 | 459 | ( |
| 458 | - f"Last tool failure: resend `patch` for `{target}` with real patch text or structured hunks." | |
| 459 | - if target | |
| 460 | + f"Last tool failure: resend `patch` for `{display_target}` with real patch text or structured hunks." | |
| 461 | + if display_target | |
| 460 | 462 | else "Last tool failure: resend `patch` with real patch text or structured hunks." |
| 461 | 463 | ), |
| 462 | 464 | f"Do not use {invalid} in place of the real patch payload.", |
@@ -744,7 +746,8 @@ class ResponseRepairer: | ||
| 744 | 746 | lines = [ |
| 745 | 747 | f"Resume with this exact next step: create `{concrete_target.name}`.", |
| 746 | 748 | f"It is the next concrete output needed to continue `{next_pending}`.", |
| 747 | - f"Prefer one `write(content=...)` call for `{concrete_target}` before more research.", | |
| 749 | + "Prefer one `write(content=...)` call for " | |
| 750 | + f"`{display_runtime_path(concrete_target)}` before more research.", | |
| 748 | 751 | self._mutation_tool_scaffold( |
| 749 | 752 | concrete_target, |
| 750 | 753 | tool_name="write", |
@@ -794,14 +797,16 @@ class ResponseRepairer: | ||
| 794 | 797 | ] |
| 795 | 798 | if inferred_is_directory: |
| 796 | 799 | lines.append( |
| 797 | - f"Prefer one concrete directory-creation step for `{inferred_pending_target}` before more research." | |
| 800 | + "Prefer one concrete directory-creation step for " | |
| 801 | + f"`{display_runtime_path(inferred_pending_target)}` before more research." | |
| 798 | 802 | ) |
| 799 | 803 | lines.append( |
| 800 | 804 | self._directory_creation_scaffold(inferred_pending_target) |
| 801 | 805 | ) |
| 802 | 806 | else: |
| 803 | 807 | lines.append( |
| 804 | - f"Prefer one `write(content=...)` call for `{inferred_pending_target}` before more research." | |
| 808 | + "Prefer one `write(content=...)` call for " | |
| 809 | + f"`{display_runtime_path(inferred_pending_target)}` before more research." | |
| 805 | 810 | ) |
| 806 | 811 | lines.append( |
| 807 | 812 | self._mutation_tool_scaffold( |
@@ -882,7 +887,8 @@ class ResponseRepairer: | ||
| 882 | 887 | ) |
| 883 | 888 | ) |
| 884 | 889 | lines.append( |
| 885 | - f"Prefer one `write` call for `{next_output_file}` before more research." | |
| 890 | + "Prefer one `write` call for " | |
| 891 | + f"`{display_runtime_path(next_output_file)}` before more research." | |
| 886 | 892 | ) |
| 887 | 893 | lines.append( |
| 888 | 894 | self._mutation_tool_scaffold( |
@@ -921,17 +927,20 @@ class ResponseRepairer: | ||
| 921 | 927 | f"under {label}." |
| 922 | 928 | ] |
| 923 | 929 | lines.append( |
| 924 | - f"Prefer one concrete `write` call for a file inside `{target}` before more research." | |
| 930 | + "Prefer one concrete `write` call for a file inside " | |
| 931 | + f"`{display_runtime_path(target)}` before more research." | |
| 925 | 932 | ) |
| 926 | 933 | else: |
| 927 | 934 | lines = [f"Resume with this exact next step: create {label}."] |
| 928 | 935 | if expect_directory and not target.is_dir(): |
| 929 | 936 | lines.append( |
| 930 | - f"Prefer one concrete directory-creation step for `{target}` before more research." | |
| 937 | + "Prefer one concrete directory-creation step for " | |
| 938 | + f"`{display_runtime_path(target)}` before more research." | |
| 931 | 939 | ) |
| 932 | 940 | elif not expect_directory: |
| 933 | 941 | lines.append( |
| 934 | - f"Prefer one `write` call for `{target}` before any more reference reads." | |
| 942 | + "Prefer one `write` call for " | |
| 943 | + f"`{display_runtime_path(target)}` before any more reference reads." | |
| 935 | 944 | ) |
| 936 | 945 | if not target.parent.exists(): |
| 937 | 946 | lines.append( |
@@ -1154,7 +1163,7 @@ class ResponseRepairer: | ||
| 1154 | 1163 | |
| 1155 | 1164 | @staticmethod |
| 1156 | 1165 | def _mutation_tool_scaffold(path: Path, *, tool_name: str) -> str: |
| 1157 | - normalized_path = json.dumps(str(path.expanduser().resolve(strict=False))) | |
| 1166 | + normalized_path = json.dumps(display_runtime_path(path)) | |
| 1158 | 1167 | if tool_name == "edit": |
| 1159 | 1168 | signature = ( |
| 1160 | 1169 | f"edit(file_path={normalized_path}, old_string=\"...\", " |
@@ -1168,7 +1177,7 @@ class ResponseRepairer: | ||
| 1168 | 1177 | |
| 1169 | 1178 | @staticmethod |
| 1170 | 1179 | def _directory_creation_scaffold(path: Path) -> str: |
| 1171 | - command = f"mkdir -p {shlex.quote(str(path.expanduser().resolve(strict=False)))}" | |
| 1180 | + command = f"mkdir -p {shlex.quote(display_runtime_path(path))}" | |
| 1172 | 1181 | return f"Emit this tool shape now: `bash(command={json.dumps(command)})`." |
| 1173 | 1182 | |
| 1174 | 1183 | |
src/loader/runtime/tool_batches.pymodified@@ -29,6 +29,7 @@ from .events import AgentEvent, TurnSummary | ||
| 29 | 29 | from .evidence_provenance import EvidenceProvenance, EvidenceProvenanceStatus |
| 30 | 30 | from .executor import ToolExecutionState, ToolExecutor |
| 31 | 31 | from .logging import get_runtime_logger |
| 32 | +from .path_display import display_runtime_path | |
| 32 | 33 | from .policy_timeline import append_verification_timeline_entry |
| 33 | 34 | from .recovery import RecoveryContext, detect_missing_mutation_payload |
| 34 | 35 | from .repair_focus import extract_active_repair_context |
@@ -1939,6 +1940,7 @@ def _resume_suffix_for_target( | ||
| 1939 | 1940 | allow_inferred_child: bool = True, |
| 1940 | 1941 | ) -> str: |
| 1941 | 1942 | label = target.name or str(target) |
| 1943 | + display_target = display_runtime_path(target) | |
| 1942 | 1944 | if expect_directory and not label.endswith("/"): |
| 1943 | 1945 | label += "/" |
| 1944 | 1946 | if expect_directory: |
@@ -1960,7 +1962,7 @@ def _resume_suffix_for_target( | ||
| 1960 | 1962 | guidance = ( |
| 1961 | 1963 | f" Resume by creating `{next_output_file.name}` now. {guidance_origin} " |
| 1962 | 1964 | f"Prefer one `write` call for " |
| 1963 | - f"`{next_output_file}` instead of more rereads." | |
| 1965 | + f"`{display_runtime_path(next_output_file)}` instead of more rereads." | |
| 1964 | 1966 | ) |
| 1965 | 1967 | if not next_output_file.parent.exists(): |
| 1966 | 1968 | guidance += ( |
@@ -1975,16 +1977,16 @@ def _resume_suffix_for_target( | ||
| 1975 | 1977 | if target.is_dir(): |
| 1976 | 1978 | return ( |
| 1977 | 1979 | f" Resume by creating the next output file under `{label}` now. Prefer one " |
| 1978 | - f"concrete `write` call for a file inside `{target}` instead of more rereads." | |
| 1980 | + f"concrete `write` call for a file inside `{display_target}` instead of more rereads." | |
| 1979 | 1981 | " Make your next response the concrete mutation tool call itself, not another" |
| 1980 | 1982 | " bookkeeping-only turn." |
| 1981 | 1983 | ) |
| 1982 | 1984 | return ( |
| 1983 | 1985 | f" Resume by creating `{label}` now. Prefer one concrete directory-creation " |
| 1984 | - f"step for `{target}` instead of more rereads." | |
| 1986 | + f"step for `{display_target}` instead of more rereads." | |
| 1985 | 1987 | ) |
| 1986 | 1988 | guidance = ( |
| 1987 | - f" Resume by creating `{label}` now. Prefer one `write` call for `{target}` " | |
| 1989 | + f" Resume by creating `{label}` now. Prefer one `write` call for `{display_target}` " | |
| 1988 | 1990 | "instead of more rereads." |
| 1989 | 1991 | ) |
| 1990 | 1992 | if not target.parent.exists(): |
@@ -2012,6 +2014,7 @@ def _compact_missing_artifact_handoff( | ||
| 2012 | 2014 | |
| 2013 | 2015 | target, expect_directory = missing_artifact |
| 2014 | 2016 | label = target.name or str(target) |
| 2017 | + display_target = display_runtime_path(target) | |
| 2015 | 2018 | if expect_directory and not label.endswith("/"): |
| 2016 | 2019 | label += "/" |
| 2017 | 2020 | if expect_directory: |
@@ -2024,15 +2027,15 @@ def _compact_missing_artifact_handoff( | ||
| 2024 | 2027 | if target.is_dir(): |
| 2025 | 2028 | return ( |
| 2026 | 2029 | f"Next step: create the next output file under `{label}`. Prefer one " |
| 2027 | - f"concrete `write` call inside `{target}` now." | |
| 2030 | + f"concrete `write` call inside `{display_target}` now." | |
| 2028 | 2031 | ) |
| 2029 | 2032 | return ( |
| 2030 | 2033 | f"Next step: create `{label}`. Prefer one concrete directory-creation step " |
| 2031 | - f"for `{target}` now." | |
| 2034 | + f"for `{display_target}` now." | |
| 2032 | 2035 | ) |
| 2033 | 2036 | guidance = ( |
| 2034 | 2037 | f"Next step: create `{next_output_file.name}`. Prefer one " |
| 2035 | - f"`write(file_path=..., content=...)` call for `{next_output_file}` now." | |
| 2038 | + f"`write(file_path=..., content=...)` call for `{display_runtime_path(next_output_file)}` now." | |
| 2036 | 2039 | ) |
| 2037 | 2040 | if not next_output_file.parent.exists(): |
| 2038 | 2041 | guidance += ( |
@@ -2043,7 +2046,7 @@ def _compact_missing_artifact_handoff( | ||
| 2043 | 2046 | |
| 2044 | 2047 | guidance = ( |
| 2045 | 2048 | f"Next step: create `{label}`. Prefer one " |
| 2046 | - f"`write(file_path=..., content=...)` call for `{target}` now." | |
| 2049 | + f"`write(file_path=..., content=...)` call for `{display_target}` now." | |
| 2047 | 2050 | ) |
| 2048 | 2051 | if not target.parent.exists(): |
| 2049 | 2052 | guidance += ( |
tests/test_repair.pymodified@@ -6,9 +6,12 @@ import json | ||
| 6 | 6 | from pathlib import Path |
| 7 | 7 | from types import SimpleNamespace |
| 8 | 8 | |
| 9 | +import pytest | |
| 10 | + | |
| 9 | 11 | from loader.llm.base import Message, Role, ToolCall |
| 10 | 12 | from loader.runtime.context import RuntimeContext |
| 11 | 13 | from loader.runtime.dod import create_definition_of_done |
| 14 | +from loader.runtime.path_display import display_runtime_path | |
| 12 | 15 | from loader.runtime.permissions import ( |
| 13 | 16 | PermissionMode, |
| 14 | 17 | build_permission_policy, |
@@ -312,7 +315,8 @@ def test_empty_response_retry_mentions_write_can_create_missing_parent_directori | ||
| 312 | 315 | in decision.retry_message |
| 313 | 316 | ) |
| 314 | 317 | assert ( |
| 315 | - f"Prefer one `write` call for `{index_path}` before any more reference reads." | |
| 318 | + "Prefer one `write` call for " | |
| 319 | + f"`{display_runtime_path(index_path)}` before any more reference reads." | |
| 316 | 320 | in decision.retry_message |
| 317 | 321 | ) |
| 318 | 322 | assert ( |
@@ -320,7 +324,7 @@ def test_empty_response_retry_mentions_write_can_create_missing_parent_directori | ||
| 320 | 324 | in decision.retry_message |
| 321 | 325 | ) |
| 322 | 326 | assert ( |
| 323 | - f'Emit this tool shape now: `write(file_path="{index_path.resolve(strict=False)}", content="...")`.' | |
| 327 | + f'Emit this tool shape now: `write(file_path="{display_runtime_path(index_path)}", content="...")`.' | |
| 324 | 328 | in decision.retry_message |
| 325 | 329 | ) |
| 326 | 330 | assert "Do not restart discovery unless one specific missing fact blocks this step." in decision.retry_message |
@@ -378,15 +382,69 @@ def test_empty_response_retry_uses_directory_creation_for_setup_targets( | ||
| 378 | 382 | in decision.retry_message |
| 379 | 383 | ) |
| 380 | 384 | assert ( |
| 381 | - f"Prefer one concrete directory-creation step for `{chapters_path}` before more research." | |
| 385 | + "Prefer one concrete directory-creation step for " | |
| 386 | + f"`{display_runtime_path(chapters_path)}` before more research." | |
| 382 | 387 | in decision.retry_message |
| 383 | 388 | ) |
| 384 | - expected_command = f"mkdir -p {chapters_path.resolve(strict=False)}" | |
| 389 | + expected_command = f"mkdir -p {display_runtime_path(chapters_path)}" | |
| 385 | 390 | assert ( |
| 386 | 391 | f'Emit this tool shape now: `bash(command="{expected_command}")`.' |
| 387 | 392 | in decision.retry_message |
| 388 | 393 | ) |
| 389 | - assert f'write(file_path="{chapters_path.resolve(strict=False)}"' not in decision.retry_message | |
| 394 | + assert f'write(file_path="{display_runtime_path(chapters_path)}"' not in decision.retry_message | |
| 395 | + | |
| 396 | + | |
| 397 | +def test_empty_response_retry_uses_home_relative_path_for_home_artifacts( | |
| 398 | + temp_dir: Path, | |
| 399 | + monkeypatch: pytest.MonkeyPatch, | |
| 400 | +) -> None: | |
| 401 | + monkeypatch.setenv("HOME", str(temp_dir.resolve(strict=False))) | |
| 402 | + context = build_context( | |
| 403 | + temp_dir=temp_dir, | |
| 404 | + use_react=False, | |
| 405 | + ) | |
| 406 | + repairer = ResponseRepairer(context) | |
| 407 | + | |
| 408 | + guide_root = temp_dir / "Loader" / "guides" / "nginx" | |
| 409 | + index_path = guide_root / "index.html" | |
| 410 | + | |
| 411 | + implementation_plan = temp_dir / "implementation.md" | |
| 412 | + implementation_plan.write_text( | |
| 413 | + "\n".join( | |
| 414 | + [ | |
| 415 | + "# Implementation Plan", | |
| 416 | + "", | |
| 417 | + "## File Changes", | |
| 418 | + f"- `{index_path}`", | |
| 419 | + "", | |
| 420 | + ] | |
| 421 | + ) | |
| 422 | + ) | |
| 423 | + | |
| 424 | + dod = create_definition_of_done("Create a multi-file nginx guide.") | |
| 425 | + dod.implementation_plan = str(implementation_plan) | |
| 426 | + dod.pending_items.extend( | |
| 427 | + [ | |
| 428 | + "Create nginx guide directory structure", | |
| 429 | + "Write main index.html for nginx guide", | |
| 430 | + ] | |
| 431 | + ) | |
| 432 | + | |
| 433 | + decision = repairer.handle_empty_response( | |
| 434 | + task="Create a multi-file nginx guide.", | |
| 435 | + original_task=None, | |
| 436 | + empty_retry_count=1, | |
| 437 | + max_empty_retries=2, | |
| 438 | + dod=dod, | |
| 439 | + ) | |
| 440 | + | |
| 441 | + assert decision.should_continue is True | |
| 442 | + assert decision.retry_message is not None | |
| 443 | + assert "`~/Loader/guides/nginx/index.html`" in decision.retry_message | |
| 444 | + assert ( | |
| 445 | + 'Emit this tool shape now: `write(file_path="~/Loader/guides/nginx/index.html", content="...")`.' | |
| 446 | + in decision.retry_message | |
| 447 | + ) | |
| 390 | 448 | |
| 391 | 449 | |
| 392 | 450 | def test_empty_response_retry_recovers_blocked_empty_file_path_to_concrete_target( |
@@ -448,12 +506,13 @@ def test_empty_response_retry_recovers_blocked_empty_file_path_to_concrete_targe | ||
| 448 | 506 | assert decision.should_continue is True |
| 449 | 507 | assert decision.retry_message is not None |
| 450 | 508 | assert ( |
| 451 | - f"Last tool failure: resend `write` for `{second_chapter}` with a valid `file_path` and real `content`." | |
| 509 | + "Last tool failure: resend `write` for " | |
| 510 | + f"`{display_runtime_path(second_chapter)}` with a valid `file_path` and real `content`." | |
| 452 | 511 | in decision.retry_message |
| 453 | 512 | ) |
| 454 | 513 | assert "Do not leave `file_path` empty" in decision.retry_message |
| 455 | 514 | assert ( |
| 456 | - f'Emit this tool shape now: `write(file_path="{second_chapter.resolve(strict=False)}", content="...")`.' | |
| 515 | + f'Emit this tool shape now: `write(file_path="{display_runtime_path(second_chapter)}", content="...")`.' | |
| 457 | 516 | in decision.retry_message |
| 458 | 517 | ) |
| 459 | 518 | |
@@ -822,7 +881,8 @@ def test_empty_response_retry_points_at_next_output_file_when_planned_directory_ | ||
| 822 | 881 | in decision.retry_message |
| 823 | 882 | ) |
| 824 | 883 | assert ( |
| 825 | - f"Prefer one concrete `write` call for a file inside `{chapters}` before more research." | |
| 884 | + "Prefer one concrete `write` call for a file inside " | |
| 885 | + f"`{display_runtime_path(chapters)}` before more research." | |
| 826 | 886 | in decision.retry_message |
| 827 | 887 | ) |
| 828 | 888 | |
@@ -1081,7 +1141,8 @@ def test_empty_response_retry_prefers_output_index_over_reference_index_with_sam | ||
| 1081 | 1141 | assert decision.should_continue is True |
| 1082 | 1142 | assert decision.retry_message is not None |
| 1083 | 1143 | assert ( |
| 1084 | - f"Prefer one `write(content=...)` call for `{output_index}` before more research." | |
| 1144 | + "Prefer one `write(content=...)` call for " | |
| 1145 | + f"`{display_runtime_path(output_index)}` before more research." | |
| 1085 | 1146 | in decision.retry_message |
| 1086 | 1147 | ) |
| 1087 | 1148 | assert str(reference_index) not in decision.retry_message |
@@ -1217,7 +1278,8 @@ def test_empty_response_retry_infers_concrete_file_from_pending_todo_after_broad | ||
| 1217 | 1278 | in decision.retry_message |
| 1218 | 1279 | ) |
| 1219 | 1280 | assert ( |
| 1220 | - f"Prefer one `write(content=...)` call for `{chapters / '02-installation.html'}` " | |
| 1281 | + "Prefer one `write(content=...)` call for " | |
| 1282 | + f"`{display_runtime_path(chapters / '02-installation.html')}` " | |
| 1221 | 1283 | "before more research." |
| 1222 | 1284 | in decision.retry_message |
| 1223 | 1285 | ) |
@@ -1294,12 +1356,13 @@ def test_empty_response_retry_maps_title_style_todo_to_html_graph_target( | ||
| 1294 | 1356 | in decision.retry_message |
| 1295 | 1357 | ) |
| 1296 | 1358 | assert ( |
| 1297 | - f"Prefer one `write(content=...)` call for `{(chapters / '02-installation.html').resolve(strict=False)}` " | |
| 1359 | + "Prefer one `write(content=...)` call for " | |
| 1360 | + f"`{display_runtime_path(chapters / '02-installation.html')}` " | |
| 1298 | 1361 | "before more research." |
| 1299 | 1362 | in decision.retry_message |
| 1300 | 1363 | ) |
| 1301 | 1364 | assert ( |
| 1302 | - f'Emit this tool shape now: `write(file_path="{(chapters / "02-installation.html").resolve(strict=False)}", content="...")`.' | |
| 1365 | + f'Emit this tool shape now: `write(file_path="{display_runtime_path(chapters / "02-installation.html")}", content="...")`.' | |
| 1303 | 1366 | in decision.retry_message |
| 1304 | 1367 | ) |
| 1305 | 1368 | assert ( |
tests/test_tool_batches.pymodified@@ -16,6 +16,7 @@ from loader.runtime.dod import ( | ||
| 16 | 16 | ) |
| 17 | 17 | from loader.runtime.events import AgentEvent, TurnSummary |
| 18 | 18 | from loader.runtime.executor import ToolExecutionOutcome, ToolExecutionState |
| 19 | +from loader.runtime.path_display import display_runtime_path | |
| 19 | 20 | from loader.runtime.permissions import ( |
| 20 | 21 | PermissionMode, |
| 21 | 22 | build_permission_policy, |
@@ -693,7 +694,8 @@ async def test_tool_batch_runner_queues_duplicate_observation_nudge( | ||
| 693 | 694 | assert "A declared output artifact is still missing." in persistent_messages[0] |
| 694 | 695 | assert "Resume by creating `04-variables.html` now." in persistent_messages[0] |
| 695 | 696 | assert ( |
| 696 | - f"Prefer one `write` call for `{temp_dir / 'chapters' / '04-variables.html'}` instead of more rereads." | |
| 697 | + "Prefer one `write` call for " | |
| 698 | + f"`{display_runtime_path(temp_dir / 'chapters' / '04-variables.html')}` instead of more rereads." | |
| 697 | 699 | in persistent_messages[0] |
| 698 | 700 | ) |
| 699 | 701 | assert ephemeral_messages == [] |
@@ -2362,6 +2364,111 @@ async def test_tool_batch_runner_first_chapter_handoff_becomes_ephemeral_after_f | ||
| 2362 | 2364 | assert "Do not reread reference material or spend the next turn on bookkeeping." in message |
| 2363 | 2365 | |
| 2364 | 2366 | |
| 2367 | +@pytest.mark.asyncio | |
| 2368 | +async def test_tool_batch_runner_directory_handoff_uses_home_relative_path( | |
| 2369 | + temp_dir: Path, | |
| 2370 | + monkeypatch: pytest.MonkeyPatch, | |
| 2371 | +) -> None: | |
| 2372 | + monkeypatch.setenv("HOME", str(temp_dir.resolve(strict=False))) | |
| 2373 | + | |
| 2374 | + async def assess_confidence( | |
| 2375 | + tool_name: str, | |
| 2376 | + tool_args: dict, | |
| 2377 | + context: str, | |
| 2378 | + ) -> ConfidenceAssessment: | |
| 2379 | + raise AssertionError("Confidence scoring should be disabled in this scenario") | |
| 2380 | + | |
| 2381 | + async def verify_action( | |
| 2382 | + tool_name: str, | |
| 2383 | + tool_args: dict, | |
| 2384 | + result: str, | |
| 2385 | + expected: str = "", | |
| 2386 | + ) -> ActionVerification: | |
| 2387 | + raise AssertionError("Verification should not run for this scenario") | |
| 2388 | + | |
| 2389 | + nginx_root = temp_dir / "Loader" / "guides" / "nginx" | |
| 2390 | + chapters = nginx_root / "chapters" | |
| 2391 | + index_path = nginx_root / "index.html" | |
| 2392 | + | |
| 2393 | + implementation_plan = temp_dir / "implementation.md" | |
| 2394 | + implementation_plan.write_text( | |
| 2395 | + "\n".join( | |
| 2396 | + [ | |
| 2397 | + "# Implementation Plan", | |
| 2398 | + "", | |
| 2399 | + "## File Changes", | |
| 2400 | + f"- `{chapters}/`", | |
| 2401 | + f"- `{index_path}`", | |
| 2402 | + "", | |
| 2403 | + ] | |
| 2404 | + ) | |
| 2405 | + ) | |
| 2406 | + | |
| 2407 | + context = build_context( | |
| 2408 | + temp_dir=temp_dir, | |
| 2409 | + messages=[], | |
| 2410 | + safeguards=FakeSafeguards(), | |
| 2411 | + assess_confidence=assess_confidence, | |
| 2412 | + verify_action=verify_action, | |
| 2413 | + auto_recover=False, | |
| 2414 | + ) | |
| 2415 | + persistent_messages: list[str] = [] | |
| 2416 | + context.queue_steering_message_callback = persistent_messages.append | |
| 2417 | + runner = ToolBatchRunner(context, DefinitionOfDoneStore(temp_dir)) | |
| 2418 | + dod = create_definition_of_done("Create a multi-file nginx guide.") | |
| 2419 | + dod.implementation_plan = str(implementation_plan) | |
| 2420 | + sync_todos_to_definition_of_done( | |
| 2421 | + dod, | |
| 2422 | + [ | |
| 2423 | + { | |
| 2424 | + "content": "Create the nginx directory structure", | |
| 2425 | + "active_form": "Creating the nginx directory structure", | |
| 2426 | + "status": "pending", | |
| 2427 | + }, | |
| 2428 | + { | |
| 2429 | + "content": "Develop the main index.html file with proper structure", | |
| 2430 | + "active_form": "Developing the main index.html file with proper structure", | |
| 2431 | + "status": "pending", | |
| 2432 | + }, | |
| 2433 | + ], | |
| 2434 | + ) | |
| 2435 | + | |
| 2436 | + tool_call = ToolCall( | |
| 2437 | + id="mkdir-nginx-home", | |
| 2438 | + name="bash", | |
| 2439 | + arguments={"command": f"mkdir -p {chapters}"}, | |
| 2440 | + ) | |
| 2441 | + executor = FakeExecutor( | |
| 2442 | + [ | |
| 2443 | + tool_outcome( | |
| 2444 | + tool_call=tool_call, | |
| 2445 | + output="", | |
| 2446 | + is_error=False, | |
| 2447 | + ) | |
| 2448 | + ] | |
| 2449 | + ) | |
| 2450 | + | |
| 2451 | + summary = TurnSummary(final_response="") | |
| 2452 | + await runner.execute_batch( | |
| 2453 | + tool_calls=[tool_call], | |
| 2454 | + tool_source="assistant", | |
| 2455 | + pending_tool_calls_seen=set(), | |
| 2456 | + emit=_noop_emit, | |
| 2457 | + summary=summary, | |
| 2458 | + dod=dod, | |
| 2459 | + executor=executor, # type: ignore[arg-type] | |
| 2460 | + on_confirmation=None, | |
| 2461 | + on_user_question=None, | |
| 2462 | + emit_confirmation=None, | |
| 2463 | + consecutive_errors=0, | |
| 2464 | + ) | |
| 2465 | + | |
| 2466 | + assert persistent_messages | |
| 2467 | + message = persistent_messages[-1] | |
| 2468 | + assert "Next step: create `index.html`." in message | |
| 2469 | + assert "`~/Loader/guides/nginx/index.html`" in message | |
| 2470 | + | |
| 2471 | + | |
| 2365 | 2472 | @pytest.mark.asyncio |
| 2366 | 2473 | async def test_tool_batch_runner_redirects_post_write_self_audit_to_next_missing_artifact( |
| 2367 | 2474 | temp_dir: Path, |
@@ -5468,7 +5575,7 @@ async def test_tool_batch_runner_blocked_empty_file_path_nudges_concrete_next_ar | ||
| 5468 | 5575 | assert "did not provide a valid `file_path`" in queued[0] |
| 5469 | 5576 | assert "Resume by creating `02-installation.html` now." in queued[0] |
| 5470 | 5577 | assert ( |
| 5471 | - f"Prefer one `write` call for `{chapter_two}` instead of more rereads." | |
| 5578 | + f"Prefer one `write` call for `{display_runtime_path(chapter_two)}` instead of more rereads." | |
| 5472 | 5579 | in queued[0] |
| 5473 | 5580 | ) |
| 5474 | 5581 | assert context.recovery_context is not None |