diff --git a/tests/test_loop.py b/tests/test_loop.py index 6ad25ecf..ef0a0ae8 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -214,6 +214,28 @@ def create_message(self, payload, stream): } +class FakeClientBashThenGenericFallbacks: + def __init__(self): + self.calls = 0 + + def create_message(self, payload, stream): + self.calls += 1 + if self.calls == 1: + return { + "id": "1", + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "tool-1", "name": "Bash", "input": {"command": "pwd"}}, + ], + } + return { + "id": str(self.calls), + "role": "assistant", + "content": [{"type": "text", "text": "What would you like me to help you with?"}], + "stop_reason": "end_turn", + } + + def test_loop_retries_on_empty_assistant_turn(tmp_path: Path): client = FakeClientEmptyThenDone() runner = Runner(client=client, repo=tmp_path, model="m", stream=False) @@ -348,6 +370,25 @@ def test_loop_stops_after_retry_limit_on_empty_turns(tmp_path: Path): assert result["response"]["content"] == [] +def test_regular_runner_generic_reply_with_failing_verification_is_not_success(tmp_path: Path): + client = FakeClientBashThenGenericFallbacks() + runner = Runner(client=client, repo=tmp_path, model="m", stream=False) + + def failing_verification(trigger="edit"): + runner._last_validation_summary = "status=fail; confidence=0.2" + if runner._mission_state is not None: + runner._mission_state.validation_failures = ["tests failing"] + return "\nstatus: fail\n" + + runner._run_verification = failing_verification + out = runner.run("fix failing test") + + assert client.calls == 3 + execution = out.get("execution", {}) + assert execution.get("terminated_reason") == "stalled_context_loss" + assert execution.get("completed") is False + + class FakeClientDiffProposal: def __init__(self): self.calls = 0 diff --git a/tests/test_mission_state_runtime.py b/tests/test_mission_state_runtime.py index ef3651c3..0f6aa667 100644 --- a/tests/test_mission_state_runtime.py +++ b/tests/test_mission_state_runtime.py @@ -5,6 +5,7 @@ from villani_code.context_projection import build_model_context_packet from villani_code.debug_bundle import create_debug_bundle +from villani_code.execution_memento import build_execution_memento, load_execution_memento from villani_code.event_recorder import RuntimeEventRecorder from villani_code.mission_state import ( MissionState, @@ -34,6 +35,8 @@ def test_mission_state_roundtrip(tmp_path: Path) -> None: mode="execution", repo_root=str(tmp_path), status="active", + last_memento_path="memo.json", + last_memento_turn_index=3, verified_facts=[VerifiedFact(kind="k", value="v", source="s")], open_hypotheses=[OpenHypothesis(hypothesis_id="h1", statement="maybe", confidence=0.4, status="open")], ) @@ -41,6 +44,8 @@ def test_mission_state_roundtrip(tmp_path: Path) -> None: loaded = load_mission_state(tmp_path, "m1") assert loaded.verified_facts[0].value == "v" assert loaded.open_hypotheses[0].hypothesis_id == "h1" + assert loaded.last_memento_path == "memo.json" + assert loaded.last_memento_turn_index == 3 def test_mission_directory_and_current_pointer(tmp_path: Path) -> None: @@ -75,6 +80,17 @@ def test_transcript_save_updates_mission_state(tmp_path: Path) -> None: path = runner._save_transcript_and_link({"requests": [], "responses": []}) loaded = load_mission_state(tmp_path, runner._mission_id) assert loaded.last_transcript_path == str(path) + assert loaded.last_memento_path.endswith("execution_memento.json") + + +def test_execution_memento_is_saved_and_loadable(tmp_path: Path) -> None: + runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) + runner._ensure_mission("objective") + state = load_mission_state(tmp_path, runner._mission_id) + memento = load_execution_memento(tmp_path, runner._mission_id) + assert state.last_memento_path.endswith("execution_memento.json") + assert memento is not None + assert memento.objective == "objective" def test_plan_artifact_serialization_roundtrip() -> None: @@ -131,6 +147,16 @@ def test_context_projection_excludes_runtime_artifacts(tmp_path: Path) -> None: assert packet["intended_targets"] == ["tests/test_app.py"] +def test_execution_memento_excludes_runtime_artifacts(tmp_path: Path) -> None: + runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) + runner._ensure_mission("do x") + runner._mission_state.changed_files = ["src/app.py", ".villani_code/missions/m1/state.json"] + runner._mission_state.intended_targets = ["./.villani_code/sessions/last.json", "tests/test_app.py"] + memento = build_execution_memento(runner) + assert memento.changed_files == ["src/app.py"] + assert memento.in_scope_files == ["tests/test_app.py"] + + def test_projected_context_not_injected_on_initial_turn_even_when_requested(tmp_path: Path) -> None: runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) runner._ensure_mission("objective") @@ -179,7 +205,7 @@ def test_projected_context_injected_into_latest_safe_user_turn(tmp_path: Path) - runner._inject_projected_context(messages) assert messages[0]["content"][0]["text"] == "older prompt" assert messages[2]["content"] == [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "ok"}] - assert "Mission context packet:" in messages[3]["content"][0]["text"] + assert "EXECUTION STATE" in messages[3]["content"][0]["text"] def test_projected_context_injected_into_string_user_content(tmp_path: Path) -> None: @@ -191,7 +217,7 @@ def test_projected_context_injected_into_string_user_content(tmp_path: Path) -> ] runner._inject_projected_context(messages) assert isinstance(messages[1]["content"], str) - assert "Mission context packet:" in messages[1]["content"] + assert "EXECUTION STATE" in messages[1]["content"] assert messages[1]["content"].endswith("final prompt") diff --git a/tests/test_state_runtime.py b/tests/test_state_runtime.py index 16e604a7..fffad688 100644 --- a/tests/test_state_runtime.py +++ b/tests/test_state_runtime.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from pathlib import Path from types import SimpleNamespace @@ -14,6 +15,25 @@ def create_message(self, _payload, stream): return {"content": [{"type": "text", "text": "ok"}]} +class _Inventory: + def __init__(self) -> None: + self.task_id = "" + + +class _GovernanceStub: + def load_inventory(self): + return _Inventory() + + def register_item(self, *args, **kwargs): + return None + + def prune_for_budget(self, *args, **kwargs): + return None + + def save_inventory(self, *args, **kwargs): + return None + + def _seed_repo(repo: Path) -> None: (repo / "villani_code").mkdir(parents=True, exist_ok=True) (repo / "villani_code" / "__init__.py").write_text("", encoding="utf-8") @@ -147,6 +167,132 @@ def test_validate_anthropic_tool_sequence_rejects_non_user_followup() -> None: state_runtime.validate_anthropic_tool_sequence(messages) +def test_prepare_messages_for_regular_runner_injects_execution_memento(tmp_path: Path) -> None: + _seed_repo(tmp_path) + runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False) + runner._context_governance = _GovernanceStub() + runner._ensure_mission("fix parser") + messages = [ + {"role": "system", "content": [{"type": "text", "text": "sys"}]}, + {"role": "user", "content": [{"type": "text", "text": "do it"}]}, + ] + prepared = state_runtime.prepare_messages_for_model(runner, messages) + flattened = str(prepared) + assert "EXECUTION STATE" in flattened + assert "Mission context packet:" not in flattened + + +def test_prepare_messages_for_model_does_not_duplicate_memento_injection(tmp_path: Path) -> None: + _seed_repo(tmp_path) + runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False) + runner._context_governance = _GovernanceStub() + runner._ensure_mission("fix parser") + messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}] + first = state_runtime.prepare_messages_for_model(runner, messages) + second = state_runtime.prepare_messages_for_model(runner, first) + count = str(second).count("EXECUTION STATE") + assert count == 1 + + +def test_prepare_messages_for_model_regular_trim_keeps_tool_sequence_integrity(tmp_path: Path) -> None: + _seed_repo(tmp_path) + runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False) + runner._context_governance = _GovernanceStub() + runner._ensure_mission("fix parser") + messages = [{"role": "system", "content": [{"type": "text", "text": "sys"}]}] + for idx in range(8): + messages.extend( + [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{idx}", "name": "Read", "input": {"file_path": "a.py"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{idx}", "content": "ok", "is_error": False}]}, + ] + ) + messages.append({"role": "user", "content": [{"type": "text", "text": "latest task"}]}) + prepared = state_runtime.prepare_messages_for_model(runner, messages) + assert len(prepared) < len(messages) + state_runtime.validate_anthropic_tool_sequence(prepared) + + +def test_prepare_messages_falls_back_when_memento_required_fields_missing(tmp_path: Path) -> None: + _seed_repo(tmp_path) + runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False) + runner._context_governance = _GovernanceStub() + runner._ensure_mission("fix parser") + if runner._mission_state is not None: + memento_path = tmp_path / ".villani_code" / "missions" / runner._mission_state.mission_id / "execution_memento.json" + payload = json.loads(memento_path.read_text(encoding="utf-8")) + payload["objective"] = "" + memento_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}] + prepared = state_runtime.prepare_messages_for_model(runner, messages) + flattened = str(prepared) + assert "EXECUTION STATE" in flattened + assert "Objective:" in flattened + assert "Success:" in flattened + assert "Next action:" in flattened + + +def test_prepare_messages_falls_back_when_memento_missing(tmp_path: Path) -> None: + _seed_repo(tmp_path) + runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False) + runner._context_governance = _GovernanceStub() + runner._ensure_mission("fix parser") + if runner._mission_state is not None: + memento_path = tmp_path / ".villani_code" / "missions" / runner._mission_state.mission_id / "execution_memento.json" + memento_path.unlink() + messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}] + prepared = state_runtime.prepare_messages_for_model(runner, messages) + flattened = str(prepared) + assert "Objective:" in flattened + assert "Success:" in flattened + assert "Next action:" in flattened + + +def test_regular_trim_keeps_original_task_anchor_and_recent_tail() -> None: + messages = [ + {"role": "system", "content": [{"type": "text", "text": "system-a"}]}, + {"role": "system", "content": [{"type": "text", "text": "system-b"}]}, + {"role": "user", "content": [{"type": "text", "text": "ORIGINAL TASK ANCHOR"}]}, + ] + for idx in range(6): + messages.extend( + [ + {"role": "assistant", "content": [{"type": "tool_use", "id": f"t{idx}", "name": "Read", "input": {"file_path": "a.py"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": f"t{idx}", "content": "ok", "is_error": False}]}, + ] + ) + messages.append({"role": "user", "content": [{"type": "text", "text": "latest tail message"}]}) + trimmed = state_runtime._trim_regular_turn_messages(messages, keep_units=3) + flattened = str(trimmed) + assert "system-a" in flattened and "system-b" in flattened + assert "ORIGINAL TASK ANCHOR" in flattened + assert "latest tail message" in flattened + state_runtime.validate_anthropic_tool_sequence(trimmed) + + +def test_prepare_messages_uses_saved_memento_over_fallback(tmp_path: Path) -> None: + _seed_repo(tmp_path) + runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, small_model=False) + runner._context_governance = _GovernanceStub() + runner._ensure_mission("fix parser") + if runner._mission_state is not None: + memento_path = tmp_path / ".villani_code" / "missions" / runner._mission_state.mission_id / "execution_memento.json" + payload = json.loads(memento_path.read_text(encoding="utf-8")) + payload["current_subgoal"] = "subgoal-from-memento" + payload["next_best_action"] = "memento-next-action" + payload["success_predicate"] = "memento-success" + payload["objective"] = "memento-objective" + payload["current_hypothesis"] = "memento-hypothesis" + payload["pinned_constraints"] = ["constraint-a"] + memento_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + prepared = state_runtime.prepare_messages_for_model( + runner, [{"role": "user", "content": [{"type": "text", "text": "do it"}]}] + ) + flattened = str(prepared) + assert "Subgoal: subgoal-from-memento" in flattened + assert "Next action: memento-next-action" in flattened + + def test_run_verification_targets_touched_tests(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _seed_repo(tmp_path) (tmp_path / "tests").mkdir(exist_ok=True) diff --git a/villani_code/execution_memento.py b/villani_code/execution_memento.py new file mode 100644 index 00000000..1009b997 --- /dev/null +++ b/villani_code/execution_memento.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING + +from villani_code.context_projection import _filter_model_facing_paths +from villani_code.mission_state import get_mission_dir +from villani_code.utils import ensure_dir + +if TYPE_CHECKING: + from villani_code.state import Runner + + +@dataclass(slots=True) +class ExecutionMemento: + objective: str + current_subgoal: str + current_step_id: str + + strongest_evidence: list[str] = field(default_factory=list) + current_hypothesis: str = "" + rejected_hypotheses: list[str] = field(default_factory=list) + + in_scope_files: list[str] = field(default_factory=list) + out_of_scope_files: list[str] = field(default_factory=list) + changed_files: list[str] = field(default_factory=list) + + last_action: str = "" + last_verification_result: str = "" + unresolved_blocker: str = "" + + next_best_action: str = "" + next_action_reason: str = "" + + pinned_constraints: list[str] = field(default_factory=list) + success_predicate: str = "" + + turn_index: int = 0 + updated_at: str = "" + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + @classmethod + def from_dict(cls, payload: dict[str, object]) -> "ExecutionMemento": + return cls( + objective=str(payload.get("objective", "")), + current_subgoal=str(payload.get("current_subgoal", "")), + current_step_id=str(payload.get("current_step_id", "")), + strongest_evidence=[str(v) for v in payload.get("strongest_evidence", [])][:5], + current_hypothesis=str(payload.get("current_hypothesis", "")), + rejected_hypotheses=[str(v) for v in payload.get("rejected_hypotheses", [])][:5], + in_scope_files=[str(v) for v in payload.get("in_scope_files", [])][:8], + out_of_scope_files=[str(v) for v in payload.get("out_of_scope_files", [])][:8], + changed_files=[str(v) for v in payload.get("changed_files", [])][:8], + last_action=str(payload.get("last_action", "")), + last_verification_result=str(payload.get("last_verification_result", "")), + unresolved_blocker=str(payload.get("unresolved_blocker", "")), + next_best_action=str(payload.get("next_best_action", "")), + next_action_reason=str(payload.get("next_action_reason", "")), + pinned_constraints=[str(v) for v in payload.get("pinned_constraints", [])], + success_predicate=str(payload.get("success_predicate", "")), + turn_index=int(payload.get("turn_index", 0) or 0), + updated_at=str(payload.get("updated_at", "")), + ) + + +def _normalize_items(items: list[str], limit: int) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for item in items: + text = str(item).strip() + if not text or text in seen: + continue + seen.add(text) + out.append(text) + if len(out) >= limit: + break + return out + + +def build_execution_memento(runner: "Runner") -> ExecutionMemento: + mission = getattr(runner, "_mission_state", None) + contract = getattr(runner, "_task_contract", {}) or {} + verified = [str(f.value) for f in getattr(mission, "verified_facts", [])] + open_hyp = [str(h.statement) for h in getattr(mission, "open_hypotheses", []) if str(h.status).lower() != "rejected"] + rejected_hyp = [str(h.statement) for h in getattr(mission, "open_hypotheses", []) if str(h.status).lower() == "rejected"] + + validation_failures = [str(v) for v in getattr(mission, "validation_failures", [])] + last_failed_summary = str(getattr(mission, "last_failed_summary", "") or "").strip() + last_failed_command = str(getattr(mission, "last_failed_command", "") or "").strip() + last_validation_summary = str(getattr(runner, "_last_validation_summary", "") or "").strip() + + evidence: list[str] = [] + evidence.extend(verified[:3]) + if last_validation_summary: + evidence.append(f"latest verification summary: {last_validation_summary}") + if validation_failures: + evidence.append(f"validation failure: {validation_failures[0]}") + if last_failed_command: + evidence.append(f"failed command: {last_failed_command}") + + intended_targets = _filter_model_facing_paths(list(getattr(mission, "intended_targets", []))) if mission else [] + changed_files = _filter_model_facing_paths(list(getattr(mission, "changed_files", []))) if mission else [] + + no_go = [str(v) for v in contract.get("no_go_paths", []) if str(v).strip()] + constraints = [ + str(v) + for v in [ + f"task mode: {contract.get('task_mode', '')}" if contract.get("task_mode") else "", + f"risk: {contract.get('risk', '')}" if contract.get("risk") else "", + ] + if str(v).strip() + ] + constraints.extend(f"no-go: {v}" for v in no_go[:4]) + if not constraints: + constraints = ["prefer surgical patch", "stay within intended target files"] + + objective = str(getattr(mission, "objective", "") or "").strip() + success_predicate = str(contract.get("success_predicate", "") or "").strip() + if not success_predicate: + success_predicate = "deliver a bounded patch with verification signal" + + current_hypothesis = open_hyp[0] if open_hyp else (last_failed_summary or "current issue is localized to intended targets") + unresolved = "" + if validation_failures: + unresolved = validation_failures[0] + elif last_failed_summary: + unresolved = last_failed_summary + + step = str(getattr(mission, "current_step_id", "") or "").strip() + if not step: + step = "verification" if (last_validation_summary or validation_failures) else "patching" + + next_action = "inspect latest failing signal in intended target and patch minimally" + if validation_failures: + next_action = "repair failing validation in current scope" + elif not changed_files and intended_targets: + next_action = f"apply first minimal patch in {intended_targets[0]}" + + turn_index = int(getattr(runner, "_current_turn_index", 0) or 0) + return ExecutionMemento( + objective=objective, + current_subgoal=str(getattr(mission, "plan_summary", "") or "").strip() or "advance the active mission step", + current_step_id=step, + strongest_evidence=_normalize_items(evidence, 5), + current_hypothesis=current_hypothesis, + rejected_hypotheses=_normalize_items(rejected_hyp, 5), + in_scope_files=_normalize_items(intended_targets, 8), + out_of_scope_files=_normalize_items(no_go, 8), + changed_files=_normalize_items(changed_files, 8), + last_action=(last_failed_command or "updated mission state")[:200], + last_verification_result=(last_validation_summary or (validation_failures[0] if validation_failures else "verification not yet run"))[:220], + unresolved_blocker=unresolved[:220], + next_best_action=next_action, + next_action_reason="it is the shortest path to satisfy the success predicate with bounded scope", + pinned_constraints=_normalize_items(constraints, 8), + success_predicate=success_predicate, + turn_index=turn_index, + updated_at=datetime.now(timezone.utc).isoformat(), + ) + + +def save_execution_memento(repo: Path, mission_id: str, memento: ExecutionMemento) -> Path: + mission_dir = get_mission_dir(repo.resolve(), mission_id) + ensure_dir(mission_dir) + path = mission_dir / "execution_memento.json" + path.write_text(json.dumps(memento.to_dict(), indent=2), encoding="utf-8") + md = mission_dir / "execution_memento.md" + md.write_text(render_execution_memento_for_model(memento) + "\n", encoding="utf-8") + return path + + +def load_execution_memento(repo: Path, mission_id: str) -> ExecutionMemento | None: + path = get_mission_dir(repo.resolve(), mission_id) / "execution_memento.json" + if not path.exists(): + return None + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + return None + return ExecutionMemento.from_dict(payload) + + +def render_execution_memento_for_model(memento: ExecutionMemento) -> str: + lines = [ + "EXECUTION STATE", + f"Objective: {memento.objective}", + f"Subgoal: {memento.current_subgoal}", + f"Step: {memento.current_step_id}", + f"Hypothesis: {memento.current_hypothesis}", + "Evidence:", + *[f"- {line}" for line in memento.strongest_evidence[:5]], + "Rejected:", + *[f"- {line}" for line in memento.rejected_hypotheses[:5]], + "In scope:", + *[f"- {line}" for line in memento.in_scope_files[:8]], + "Changed:", + *[f"- {line}" for line in memento.changed_files[:8]], + f"Last verification: {memento.last_verification_result}", + f"Blocker: {memento.unresolved_blocker}", + f"Next action: {memento.next_best_action}", + f"Why next: {memento.next_action_reason}", + "Constraints:", + *[f"- {line}" for line in memento.pinned_constraints[:8]], + f"Success: {memento.success_predicate}", + ] + return "\n".join(lines) + + +def build_local_evidence_block(runner: "Runner") -> str: + items: list[str] = [] + target = str(getattr(runner, "_last_validation_target", "") or "").strip() + summary = str(getattr(runner, "_last_validation_summary", "") or "").strip() + pending = str(getattr(runner, "_pending_verification", "") or "").strip() + mission = getattr(runner, "_mission_state", None) + if target: + items.append(f"last_validation_target: {target}") + if summary: + items.append(f"last_validation_summary: {summary}") + if pending: + items.append(f"pending_verification: {pending.splitlines()[0][:180]}") + if mission is not None and getattr(mission, "last_failed_command", ""): + items.append(f"last_failed_command: {mission.last_failed_command}") + if mission is not None and getattr(mission, "last_failed_summary", ""): + items.append(f"last_failed_summary: {str(mission.last_failed_summary)[:180]}") + if not items: + return "" + return "LOCAL EVIDENCE\n" + "\n".join(f"- {line}" for line in items[:4]) + + +def build_fallback_execution_state_block(runner: "Runner") -> str: + mission = getattr(runner, "_mission_state", None) + contract = getattr(runner, "_task_contract", {}) or {} + + objective = str(getattr(mission, "objective", "") or "").strip() or "Continue the active repair mission" + success = str(contract.get("success_predicate", "") or "").strip() + if not success: + success = "Complete the requested task and make verification pass" + + validation_failures = [str(v).strip() for v in getattr(mission, "validation_failures", []) if str(v).strip()] + last_validation_summary = str(getattr(runner, "_last_validation_summary", "") or "").strip() + last_failed_summary = str(getattr(mission, "last_failed_summary", "") or "").strip() + if last_validation_summary: + current_verification = last_validation_summary + elif validation_failures: + current_verification = validation_failures[0] + elif last_failed_summary: + current_verification = last_failed_summary + else: + current_verification = "verification still pending" + + changed_files = list(getattr(mission, "changed_files", []) if mission is not None else []) + intended_targets = list(getattr(mission, "intended_targets", []) if mission is not None else []) + if validation_failures or any(token in current_verification.lower() for token in ("fail", "error", "uncertain")): + next_action = "Fix the remaining failing verification and rerun tests" + elif changed_files and "pass" not in current_verification.lower(): + next_action = "Run verification to confirm the fix" + elif intended_targets: + next_action = "Inspect and repair the most likely target file" + else: + next_action = "Identify the failing component and continue repair" + + pinned_constraints = [str(v).strip() for v in contract.get("no_go_paths", []) if str(v).strip()] + task_mode = str(contract.get("task_mode", "") or "").strip() + if task_mode: + pinned_constraints.insert(0, f"task mode: {task_mode}") + constraints_text = ", ".join(pinned_constraints[:4]) if pinned_constraints else "prefer surgical patch" + + lines = [ + "EXECUTION STATE", + f"Objective: {objective}", + f"Success: {success}", + f"Current verification: {current_verification[:220]}", + f"Next action: {next_action}", + f"Constraints: {constraints_text}", + ] + return "\n".join(lines) diff --git a/villani_code/mission_state.py b/villani_code/mission_state.py index 8552f94d..5c34e49f 100644 --- a/villani_code/mission_state.py +++ b/villani_code/mission_state.py @@ -54,6 +54,8 @@ class MissionState: last_failed_summary: str = "" last_checkpoint_id: str = "" last_transcript_path: str = "" + last_memento_path: str = "" + last_memento_turn_index: int = 0 compact_summary: str = "" autonomous_wave: int = 0 autonomous_backlog_summary: list[str] = field(default_factory=list) @@ -85,6 +87,8 @@ def from_dict(cls, payload: dict[str, Any]) -> "MissionState": last_failed_summary=str(payload.get("last_failed_summary", "")), last_checkpoint_id=str(payload.get("last_checkpoint_id", "")), last_transcript_path=str(payload.get("last_transcript_path", "")), + last_memento_path=str(payload.get("last_memento_path", "")), + last_memento_turn_index=int(payload.get("last_memento_turn_index", 0)), compact_summary=str(payload.get("compact_summary", "")), autonomous_wave=int(payload.get("autonomous_wave", 0)), autonomous_backlog_summary=[str(v) for v in payload.get("autonomous_backlog_summary", [])], diff --git a/villani_code/state.py b/villani_code/state.py index 1a2c80d4..df8e814f 100644 --- a/villani_code/state.py +++ b/villani_code/state.py @@ -39,6 +39,13 @@ from villani_code.tools import tool_specs from villani_code.transcripts import save_transcript from villani_code.context_projection import build_model_context_packet, render_model_context_packet +from villani_code.execution_memento import ( + build_execution_memento, + build_local_evidence_block, + load_execution_memento, + render_execution_memento_for_model, + save_execution_memento, +) from villani_code.event_recorder import RuntimeEventRecorder from villani_code.debug_mode import DebugConfig, DebugMode from villani_code.debug_recorder import DebugRecorder @@ -880,6 +887,7 @@ def run( consecutive_recon_turns = 0 benchmark_prose_only_after_forced_read = 0 benchmark_forced_read_no_progress_guard_active = initial_read_enforced + generic_completion_guard_count = 0 # Conservative benchmark-only fast-fail for repeated out-of-scope mutation attempts. benchmark_mutation_denials = 0 benchmark_denial_limit = 3 @@ -1201,6 +1209,32 @@ def _budget_reason( self.event_callback({"type": "benchmark_scope_reminder_injected", "task_id": self.benchmark_config.task_id, "reason": "no_meaningful_edit"}) messages.append({"role": "user", "content": [{"type": "text", "text": reminder}]}) continue + if self._should_block_regular_completion_on_generic_reply(response): + generic_completion_guard_count += 1 + self.event_callback( + { + "type": "regular_completion_blocked_context_loss", + "attempt": generic_completion_guard_count, + } + ) + if generic_completion_guard_count >= 2: + return _finish_bounded(response, "stalled_context_loss", False) + messages.append( + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "Continue the active repair loop. Latest verification is still unresolved." + " Follow the execution state block, take the next concrete repair or verification action," + " and do not ask generic follow-up questions." + ), + } + ], + } + ) + continue reason = _budget_reason(completed=True) if reason: return _finish_bounded(response, reason, reason == "completed") @@ -1329,6 +1363,33 @@ def _budget_reason( self.event_callback({"type": "benchmark_scope_reminder_injected", "task_id": self.benchmark_config.task_id, "reason": "no_meaningful_edit"}) messages.append({"role": "user", "content": [{"type": "text", "text": reminder}]}) continue + if self._should_block_regular_completion_on_generic_reply(response): + generic_completion_guard_count += 1 + self.event_callback( + { + "type": "regular_completion_blocked_context_loss", + "attempt": generic_completion_guard_count, + } + ) + if generic_completion_guard_count >= 2: + return _finish_bounded(response, "stalled_context_loss", False) + messages.append( + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "Continue the active repair loop. Latest verification is still unresolved." + " Follow the execution state block, take the next concrete repair or verification action," + " and do not ask generic follow-up questions." + ), + } + ], + } + ) + continue + generic_completion_guard_count = 0 reason = _budget_reason(completed=True) if reason: return _finish_bounded(response, reason, reason == "completed") @@ -1586,12 +1647,31 @@ def _ensure_mission(self, instruction: str) -> None: def _update_mission_state(self, **fields: Any) -> None: if self._mission_state is None: return + changed_keys = set(fields.keys()) for key, value in fields.items(): if hasattr(self._mission_state, key): setattr(self._mission_state, key, value) self._mission_state.intended_targets = sorted(self._intended_targets) if self._mission_state.status == "active": self._mission_state.changed_files = self._git_changed_files() + meaningful_update_keys = { + "current_step_id", + "plan_summary", + "changed_files", + "intended_targets", + "validation_failures", + "last_failed_command", + "last_failed_summary", + "last_checkpoint_id", + "objective", + "status", + } + should_refresh_memento = bool(changed_keys & meaningful_update_keys) or not self._mission_state.last_memento_path + if should_refresh_memento: + memento = build_execution_memento(self) + path = save_execution_memento(self.repo, self._mission_state.mission_id, memento) + self._mission_state.last_memento_path = str(path) + self._mission_state.last_memento_turn_index = int(self._current_turn_index or 0) save_mission_state(self.repo, self._mission_state) if self._debug_recorder is not None: self._debug_recorder.record_mission_state_snapshot( @@ -1605,6 +1685,29 @@ def _inject_projected_context(self, messages: list[dict[str, Any]]) -> None: return if len(messages) <= 1: return + memento = load_execution_memento(self.repo, self._mission_state.mission_id) + if memento is not None: + required = ( + bool(memento.objective.strip()), + bool(memento.success_predicate.strip()), + bool(memento.pinned_constraints), + bool(memento.current_hypothesis.strip()), + bool(memento.next_best_action.strip()), + ) + if all(required): + rendered = render_execution_memento_for_model(memento) + local = build_local_evidence_block(self) + block = rendered if not local else f"{rendered}\n\n{local}" + if not any( + "EXECUTION STATE" in str(content_block.get("text", "")) + for message in messages + for content_block in (message.get("content", []) if isinstance(message.get("content", []), list) else []) + if isinstance(content_block, dict) + ): + from villani_code import state_runtime + + state_runtime.prepend_text_to_latest_safe_user_message(messages, block) + return if any( "Mission context packet:" in str(block.get("text", "")) for msg in messages @@ -1770,6 +1873,11 @@ def _is_no_progress_response(self, response: dict[str, Any]) -> bool: return state_runtime.is_no_progress_response(response) + def _should_block_regular_completion_on_generic_reply(self, response: dict[str, Any]) -> bool: + from villani_code import state_runtime + + return state_runtime.should_block_regular_completion_on_generic_reply(self, response) + def _save_session_snapshot(self, messages: list[dict[str, Any]]) -> None: from villani_code import state_runtime diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index 9e03824d..d2a0f864 100644 --- a/villani_code/state_runtime.py +++ b/villani_code/state_runtime.py @@ -12,6 +12,12 @@ from typing import Any from villani_code.autonomy import VerificationStatus +from villani_code.execution_memento import ( + build_fallback_execution_state_block, + build_local_evidence_block, + load_execution_memento, + render_execution_memento_for_model, +) from villani_code.indexing import DEFAULT_IGNORE, RepoIndex from villani_code.live_display import apply_live_display_delta from villani_code.planning import TaskMode, generate_execution_plan @@ -375,6 +381,12 @@ def prepare_messages_for_model(runner: Any, messages: list[dict[str, Any]]) -> l inject_retrieval_briefing(runner, prepared) if runner._context_budget: prepared = runner._context_budget.compact(prepared) + elif not runner.villani_mode: + _inject_execution_state_memory(runner, prepared) + prepared = _trim_regular_turn_messages(prepared, keep_units=4) + if runner._context_budget and _messages_chars(prepared) > runner._context_budget.max_chars: + prepared = runner._context_budget.compact_session_messages(prepared) + _ensure_regular_execution_state_invariant(runner, prepared) inventory = runner._context_governance.load_inventory() inventory.task_id = str(getattr(getattr(runner, "_execution_plan", None), "task_goal", "task"))[:80] or "task" total_chars = sum(len(str(m.get("content", ""))) for m in prepared) @@ -393,6 +405,158 @@ def prepare_messages_for_model(runner: Any, messages: list[dict[str, Any]]) -> l return prepared +def _inject_execution_state_memory(runner: Any, messages: list[dict[str, Any]]) -> None: + mission = getattr(runner, "_mission_state", None) + if mission is None: + return + if any( + isinstance(block, dict) and "EXECUTION STATE" in str(block.get("text", "")) + for message in messages + for block in (message.get("content", []) if isinstance(message.get("content", []), list) else []) + ): + return + memento = load_execution_memento(runner.repo, mission.mission_id) + memento_block = "" + if memento is not None: + required = ( + bool(memento.objective.strip()), + bool(memento.success_predicate.strip()), + bool(memento.pinned_constraints), + bool(memento.current_hypothesis.strip()), + bool(memento.next_best_action.strip()), + ) + if all(required): + memento_block = render_execution_memento_for_model(memento) + if not memento_block: + memento_block = build_fallback_execution_state_block(runner) + evidence_block = build_local_evidence_block(runner) + text = memento_block if not evidence_block else f"{memento_block}\n\n{evidence_block}" + prepend_text_to_latest_safe_user_message(messages, text) + + +def _messages_chars(messages: list[dict[str, Any]]) -> int: + return sum(len(str(message.get("content", ""))) for message in messages) + + +def _is_tool_use_message(message: dict[str, Any]) -> bool: + if message.get("role") != "assistant": + return False + content = message.get("content", []) + return isinstance(content, list) and any( + isinstance(block, dict) and block.get("type") == "tool_use" for block in content + ) + + +def _is_tool_result_message(message: dict[str, Any]) -> bool: + if message.get("role") != "user": + return False + content = message.get("content", []) + return isinstance(content, list) and bool(content) and all( + isinstance(block, dict) and block.get("type") == "tool_result" for block in content + ) + + +def _group_atomic_units(messages: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + units: list[list[dict[str, Any]]] = [] + idx = 0 + while idx < len(messages): + current = messages[idx] + if _is_tool_use_message(current) and idx + 1 < len(messages) and _is_tool_result_message(messages[idx + 1]): + units.append([current, messages[idx + 1]]) + idx += 2 + continue + units.append([current]) + idx += 1 + return units + + +def _trim_regular_turn_messages(messages: list[dict[str, Any]], keep_units: int = 4) -> list[dict[str, Any]]: + system_messages = [message for message in messages if message.get("role") == "system"] + others = [message for message in messages if message.get("role") != "system"] + if not others: + return messages + units = _group_atomic_units(others) + anchor_message = next((message for message in others if not _is_tool_result_message(message)), None) + anchor_unit: list[dict[str, Any]] = [] + if anchor_message is not None: + anchor_unit = next((unit for unit in units if any(member is anchor_message for member in unit)), []) + tail_units = units[-max(2, keep_units):] + selected: list[dict[str, Any]] = [] + selected.extend(system_messages) + seen_ids: set[int] = set() + for message in anchor_unit: + selected.append(message) + seen_ids.add(id(message)) + for unit in tail_units: + for message in unit: + if id(message) in seen_ids: + continue + selected.append(message) + seen_ids.add(id(message)) + return selected + + +def _has_execution_state_markers(messages: list[dict[str, Any]]) -> bool: + text_fragments: list[str] = [] + for message in messages: + content = message.get("content", []) + if isinstance(content, str): + text_fragments.append(content) + continue + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text_fragments.append(str(block.get("text", ""))) + flattened = "\n".join(text_fragments) + return "Objective:" in flattened and "Success:" in flattened and "Next action:" in flattened + + +def _ensure_regular_execution_state_invariant(runner: Any, messages: list[dict[str, Any]]) -> None: + if runner.small_model or runner.villani_mode: + return + if not _has_execution_state_markers(messages): + prepend_text_to_latest_safe_user_message(messages, build_fallback_execution_state_block(runner)) + + +def should_block_regular_completion_on_generic_reply(runner: Any, response: dict[str, Any]) -> bool: + if runner.small_model or runner.villani_mode or str(getattr(runner, "_runtime_mode", "")) != "execution": + return False + content_blocks = response.get("content", []) + if not content_blocks or any(block.get("type") == "tool_use" for block in content_blocks if isinstance(block, dict)): + return False + text = " ".join( + str(block.get("text", "")).strip() + for block in content_blocks + if isinstance(block, dict) and block.get("type") == "text" + ).strip() + if not text: + return False + lowered = text.lower() + generic_markers = ( + "what would you like me to help", + "how would you like to proceed", + "how would you like to continue", + "i've reviewed the repo", + "i have reviewed the repo", + ) + looks_generic = any(marker in lowered for marker in generic_markers) or ( + "would you like" in lowered and "?" in lowered and len(lowered) < 240 + ) + if not looks_generic: + return False + + mission = getattr(runner, "_mission_state", None) + last_validation_summary = str(getattr(runner, "_last_validation_summary", "") or "").lower() + pending_verification = str(getattr(runner, "_pending_verification", "") or "").lower() + mission_failed_summary = str(getattr(mission, "last_failed_summary", "") if mission else "").lower() + has_failures = bool(getattr(mission, "validation_failures", []) if mission else []) + verification_unresolved = has_failures or any( + token in f"{last_validation_summary}\n{pending_verification}\n{mission_failed_summary}" + for token in ("fail", "error", "uncertain", "pending", "not yet run") + ) + return verification_unresolved + + def validate_anthropic_tool_sequence(messages: list[dict[str, Any]]) -> None: for index, message in enumerate(messages): if message.get("role") != "assistant":