diff --git a/tests/test_bounded_execution.py b/tests/test_bounded_execution.py index ed08c70f..28fcf13b 100644 --- a/tests/test_bounded_execution.py +++ b/tests/test_bounded_execution.py @@ -110,3 +110,18 @@ def test_villani_task_reports_completed_when_done(tmp_path: Path) -> None: assert result["execution"]["completed"] is True assert result["execution"]["terminated_reason"] == "completed" + + +def test_regular_runner_generic_no_tool_reply_with_unresolved_verification_does_not_complete(tmp_path: Path) -> None: + runner = _runner( + tmp_path, + [ + {"role": "assistant", "content": [{"type": "text", "text": "What would you like me to help you with?"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "How would you like to proceed?"}]}, + ], + ) + + result = runner.run("repair failing tests") + + assert result["execution"]["completed"] is False + assert result["execution"]["terminated_reason"] == "stalled_context_loss" 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..488c95f2 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,119 @@ 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 "EXECUTION STATE" in flattened + assert "Objective:" in flattened + assert "Success:" in flattened + assert "Next action:" in flattened + + +def test_trim_regular_turn_messages_keeps_task_anchor_and_recent_tail() -> None: + messages = [ + {"role": "system", "content": [{"type": "text", "text": "sys"}]}, + {"role": "user", "content": [{"type": "text", "text": "ORIGINAL TASK"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "ack"}]}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "Read", "input": {"file_path": "a.py"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "t2", "name": "Read", "input": {"file_path": "b.py"}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t2", "content": "ok"}]}, + {"role": "user", "content": [{"type": "text", "text": "latest user followup"}]}, + ] + trimmed = state_runtime._trim_regular_turn_messages(messages, keep_units=2) + flattened = str(trimmed) + assert "sys" in flattened + assert "ORIGINAL TASK" in flattened + assert "latest user followup" in flattened + state_runtime.validate_anthropic_tool_sequence(trimmed) + + +def test_prepare_messages_uses_valid_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") + messages = [{"role": "user", "content": [{"type": "text", "text": "do it"}]}] + prepared = state_runtime.prepare_messages_for_model(runner, messages) + flattened = str(prepared) + assert "Subgoal:" in flattened + assert "Why next:" 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..416559b9 --- /dev/null +++ b/villani_code/execution_memento.py @@ -0,0 +1,278 @@ +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 "Complete the requested task." + success = str(contract.get("success_predicate", "") or "").strip() + if not success: + success = "Complete the requested task and make verification pass" + + verification = str(getattr(runner, "_last_validation_summary", "") or "").strip() + failures = [str(v).strip() for v in list(getattr(mission, "validation_failures", []) or []) if str(v).strip()] + if not verification and failures: + verification = failures[0] + if not verification: + verification = str(getattr(mission, "last_failed_summary", "") or "").strip() + if not verification: + verification = "verification still pending" + + changed_files = [str(v).strip() for v in list(getattr(mission, "changed_files", []) or []) if str(v).strip()] + intended_targets = [str(v).strip() for v in list(getattr(mission, "intended_targets", []) or []) if str(v).strip()] + has_validation_signal = bool(getattr(runner, "_last_validation_summary", "").strip() or failures) + if failures: + next_action = "Fix the remaining failing verification and rerun tests" + elif changed_files and not has_validation_signal: + 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" + + constraints = [str(v).strip() for v in list(contract.get("no_go_paths", []) or []) if str(v).strip()] + pinned_constraints = [str(v).strip() for v in list(contract.get("pinned_constraints", []) or []) if str(v).strip()] + if pinned_constraints: + constraints = pinned_constraints + constraints + + lines = [ + "EXECUTION STATE", + f"Objective: {objective}", + f"Success: {success}", + f"Current verification: {verification}", + f"Next action: {next_action}", + ] + if constraints: + lines.append("Constraints:") + lines.extend(f"- {item}" for item in constraints[:6]) + 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..97d81bac 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 @@ -879,6 +886,7 @@ def run( consecutive_no_edit_turns = 0 consecutive_recon_turns = 0 benchmark_prose_only_after_forced_read = 0 + context_loss_guard_hits = 0 benchmark_forced_read_no_progress_guard_active = initial_read_enforced # Conservative benchmark-only fast-fail for repeated out-of-scope mutation attempts. benchmark_mutation_denials = 0 @@ -1068,6 +1076,38 @@ def _budget_reason( return "completed" return None + def _latest_verification_unresolved() -> bool: + if str(self._pending_verification or "").strip(): + return True + mission = self._mission_state + if mission is not None and list(getattr(mission, "validation_failures", []) or []): + return True + summary = str(self._last_validation_summary or "").strip().lower() + if not summary: + return True + if any(token in summary for token in ("fail", "error", "pending", "unresolved")): + return True + return "pass" not in summary and "success" not in summary + + def _is_generic_no_tool_fallback_response(response: dict[str, Any]) -> bool: + blocks = response.get("content", []) + text = " ".join( + str(block.get("text", "")).strip() + for block in blocks + if isinstance(block, dict) and block.get("type") == "text" + ).strip() + if not text: + return False + lowered = text.lower() + generic_phrases = ( + "what would you like me to help", + "how would you like to proceed", + "i've reviewed the repo", + "i have reviewed the repo", + "let me know how you'd like to proceed", + ) + return any(phrase in lowered for phrase in generic_phrases) + while True: self._current_turn_index = turns_used + 1 self._live_stream_buffer = "" @@ -1187,6 +1227,37 @@ def _budget_reason( } ) continue + if ( + not self.small_model + and not self.villani_mode + and _latest_verification_unresolved() + and only_textual_response + and _is_generic_no_tool_fallback_response(response) + ): + context_loss_guard_hits += 1 + self.event_callback( + { + "type": "regular_context_loss_guard_triggered", + "attempt": context_loss_guard_hits, + } + ) + if context_loss_guard_hits >= 2: + return _finish_bounded(response, "stalled_context_loss", False) + messages.append( + { + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "Context restore: continue the active code-repair loop. " + "Use the execution state block, fix the remaining failing verification, and run verification." + ), + } + ], + } + ) + continue if empty: if ( self.benchmark_config.enabled @@ -1586,12 +1657,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 +1695,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 diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index 9e03824d..a389634d 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,137 @@ 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 + text = "" + memento = load_execution_memento(runner.repo, mission.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): + memento_block = render_execution_memento_for_model(memento) + evidence_block = build_local_evidence_block(runner) + text = memento_block if not evidence_block else f"{memento_block}\n\n{evidence_block}" + if not text: + text = build_fallback_execution_state_block(runner) + 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 + task_anchor = next( + ( + message + for message in others + if message.get("role") == "user" and not _is_tool_result_message(message) + ), + None, + ) + units = _group_atomic_units(others) + tail_units = units[-max(2, keep_units):] + tail_messages = [message for unit in tail_units for message in unit] + trimmed: list[dict[str, Any]] = [*system_messages] + if task_anchor is not None and all(task_anchor is not tail for tail in tail_messages): + trimmed.append(task_anchor) + trimmed.extend(tail_messages) + return trimmed + + +def _message_contains_execution_state(message: dict[str, Any]) -> bool: + content = message.get("content") + if isinstance(content, str): + return "EXECUTION STATE" in content + if not isinstance(content, list): + return False + return any(isinstance(block, dict) and "EXECUTION STATE" in str(block.get("text", "")) for block in content) + + +def _has_durable_execution_state_block(messages: list[dict[str, Any]]) -> bool: + for message in messages: + content = message.get("content") + if isinstance(content, str): + text = content + if "Objective:" in text and "Success:" in text and "Next action:" in text: + return True + continue + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + text = str(block.get("text", "")) + if "Objective:" in text and "Success:" in text and "Next action:" in text: + return True + return False + + +def _ensure_regular_execution_state_invariant(runner: Any, messages: list[dict[str, Any]]) -> None: + if _has_durable_execution_state_block(messages): + return + if any(_message_contains_execution_state(message) for message in messages): + return + injected = prepend_text_to_latest_safe_user_message(messages, build_fallback_execution_state_block(runner)) + if not injected: + messages.append( + { + "role": "user", + "content": [{"type": "text", "text": build_fallback_execution_state_block(runner)}], + } + ) + + def validate_anthropic_tool_sequence(messages: list[dict[str, Any]]) -> None: for index, message in enumerate(messages): if message.get("role") != "assistant":