diff --git a/tests/test_mission_state_runtime.py b/tests/test_mission_state_runtime.py index ef3651c3..a93ffa77 100644 --- a/tests/test_mission_state_runtime.py +++ b/tests/test_mission_state_runtime.py @@ -4,6 +4,7 @@ from pathlib import Path from villani_code.context_projection import build_model_context_packet +from villani_code.execution_memento import build_execution_memento from villani_code.debug_bundle import create_debug_bundle from villani_code.event_recorder import RuntimeEventRecorder from villani_code.mission_state import ( @@ -36,11 +37,15 @@ def test_mission_state_roundtrip(tmp_path: Path) -> None: status="active", verified_facts=[VerifiedFact(kind="k", value="v", source="s")], open_hypotheses=[OpenHypothesis(hypothesis_id="h1", statement="maybe", confidence=0.4, status="open")], + last_memento_path="x/y/execution_memento.json", + last_memento_turn_index=3, ) save_mission_state(tmp_path, state) 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.endswith("execution_memento.json") + assert loaded.last_memento_turn_index == 3 def test_mission_directory_and_current_pointer(tmp_path: Path) -> None: @@ -160,7 +165,7 @@ def test_projected_context_not_injected_in_benchmark_mode(tmp_path: Path) -> Non runner._inject_projected_context(messages) assert messages[-1]["content"][0]["text"] == "benchmark contract prompt" assert not any( - "Mission context packet:" in str(block.get("text", "")) + "EXECUTION STATE" in str(block.get("text", "")) for message in messages for block in message.get("content", []) if isinstance(block, dict) @@ -170,6 +175,7 @@ def test_projected_context_not_injected_in_benchmark_mode(tmp_path: Path) -> Non def test_projected_context_injected_into_latest_safe_user_turn(tmp_path: Path) -> None: runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) runner._ensure_mission("objective") + runner._task_contract = {"success_predicate": "pass", "no_go_paths": [".villani_code/"]} messages = [ {"role": "user", "content": [{"type": "text", "text": "older prompt"}]}, {"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1"}]}, @@ -179,19 +185,20 @@ 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: runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) runner._ensure_mission("objective") + runner._task_contract = {"success_predicate": "pass", "no_go_paths": [".villani_code/"]} messages = [ {"role": "assistant", "content": [{"type": "text", "text": "ack"}]}, {"role": "user", "content": "final prompt"}, ] 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") @@ -207,6 +214,42 @@ def test_projected_context_skips_when_only_tool_result_user_turn_exists(tmp_path assert messages == original +def test_execution_memento_saved_to_mission_dir(tmp_path: Path) -> None: + runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) + runner._ensure_mission("objective") + runner._task_contract = {"success_predicate": "pass tests", "preferred_targets": ["src/a.py"], "no_go_paths": [".villani_code/"]} + runner._current_turn_index = 2 + runner._refresh_execution_memento() + state = load_mission_state(tmp_path, runner._mission_id) + assert state.last_memento_path.endswith("execution_memento.json") + assert state.last_memento_turn_index == 2 + memento_path = Path(state.last_memento_path) + assert memento_path.exists() + assert (memento_path.parent / "execution_memento.md").exists() + + +def test_execution_memento_excludes_runtime_artifact_paths(tmp_path: Path) -> None: + runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) + runner._ensure_mission("objective") + 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_falls_back_when_memento_required_fields_missing(tmp_path: Path) -> None: + runner = Runner(client=DummyClient(), repo=tmp_path, model="x", stream=False, print_stream=False) + runner._ensure_mission("objective") + runner._task_contract = {} + messages = [ + {"role": "assistant", "content": [{"type": "text", "text": "ack"}]}, + {"role": "user", "content": "final prompt"}, + ] + runner._inject_projected_context(messages) + assert "Mission context packet:" in messages[1]["content"] + + def test_new_mission_id_unique_for_rapid_calls() -> None: from villani_code.mission_state import new_mission_id diff --git a/tests/test_session_compaction.py b/tests/test_session_compaction.py index cdc6c5d1..418c372c 100644 --- a/tests/test_session_compaction.py +++ b/tests/test_session_compaction.py @@ -1,7 +1,7 @@ from __future__ import annotations from villani_code.context_budget import ContextBudget -from villani_code.state_runtime import validate_anthropic_tool_sequence +from villani_code.state_runtime import _trim_regular_runner_transcript_tail, validate_anthropic_tool_sequence from villani_code.tui.controller import RunnerController @@ -103,3 +103,21 @@ def test_follow_up_prompt_can_include_prior_tool_and_assistant_context() -> None ) assert sent[-1]["role"] == "user" assert "follow-up" in sent[-1]["content"][0]["text"] + + +def test_regular_runner_tail_trimming_preserves_tool_use_result_integrity() -> None: + messages = [ + {"role": "system", "content": [{"type": "text", "text": "sys"}]}, + {"role": "user", "content": [{"type": "text", "text": "start"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "a"}]}, + {"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": "text", "text": "b"}]}, + {"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": "assistant", "content": [{"type": "text", "text": "tail"}]}, + {"role": "user", "content": [{"type": "text", "text": "latest"}]}, + ] + trimmed = _trim_regular_runner_transcript_tail(messages, keep_units=2) + validate_anthropic_tool_sequence(trimmed) + assert len(trimmed) < len(messages) diff --git a/tests/test_state_runtime.py b/tests/test_state_runtime.py index 16e604a7..19cfaf67 100644 --- a/tests/test_state_runtime.py +++ b/tests/test_state_runtime.py @@ -469,6 +469,82 @@ def save_inventory(self, _inventory): ) == 1 +def test_prepare_messages_for_model_regular_injects_memento_without_duplication() -> None: + class _CtxGov: + def load_inventory(self): + return SimpleNamespace(task_id="") + + def register_item(self, *args, **kwargs): + return None + + def prune_for_budget(self, _inventory): + return None + + def save_inventory(self, _inventory): + return None + + class _Runner: + small_model = False + villani_mode = False + _context_budget = None + _execution_plan = SimpleNamespace(task_goal="t") + _context_governance = _CtxGov() + + @staticmethod + def _inject_projected_context(messages): + state_runtime.prepend_text_to_latest_safe_user_message(messages, "EXECUTION STATE\nObjective: x") + + messages = [{"role": "user", "content": [{"type": "text", "text": "Need context on runtime."}]}] + prepared_one = state_runtime.prepare_messages_for_model(_Runner(), messages) + prepared_two = state_runtime.prepare_messages_for_model(_Runner(), messages) + for prepared in (prepared_one, prepared_two): + assert sum( + 1 + for block in prepared[0]["content"] + if isinstance(block, dict) and "EXECUTION STATE" in str(block.get("text", "")) + ) == 1 + + +def test_prepare_messages_for_model_regular_trims_history_and_preserves_tool_sequence() -> None: + class _CtxGov: + def load_inventory(self): + return SimpleNamespace(task_id="") + + def register_item(self, *args, **kwargs): + return None + + def prune_for_budget(self, _inventory): + return None + + def save_inventory(self, _inventory): + return None + + runner = SimpleNamespace( + small_model=False, + villani_mode=False, + _context_budget=None, + _context_governance=_CtxGov(), + _execution_plan=SimpleNamespace(task_goal="t"), + _inject_projected_context=lambda _messages: None, + ) + messages = [ + {"role": "system", "content": [{"type": "text", "text": "sys"}]}, + {"role": "user", "content": [{"type": "text", "text": "objective"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "old 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": "text", "text": "middle"}]}, + {"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": "assistant", "content": [{"type": "text", "text": "latest"}]}, + {"role": "user", "content": [{"type": "text", "text": "next"}]}, + ] + prepared = state_runtime.prepare_messages_for_model(runner, messages) + state_runtime.validate_anthropic_tool_sequence(prepared) + assert len(prepared) < len(messages) + assert prepared[0]["role"] == "system" + + def test_fail_first_localization_runs_without_strong_signal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _seed_repo(tmp_path) events: list[dict] = [] diff --git a/villani_code/execution_memento.py b/villani_code/execution_memento.py new file mode 100644 index 00000000..d5e17758 --- /dev/null +++ b/villani_code/execution_memento.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +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] + current_hypothesis: str + rejected_hypotheses: list[str] + + in_scope_files: list[str] + out_of_scope_files: list[str] + changed_files: list[str] + + last_action: str + last_verification_result: str + unresolved_blocker: str + + next_best_action: str + next_action_reason: str + + pinned_constraints: list[str] + success_predicate: str + + turn_index: int + updated_at: str + + +def _dedupe(items: list[str], limit: int) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + value = str(item).strip() + if not value or value in seen: + continue + seen.add(value) + out.append(value) + 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 {} + objective = str(getattr(mission, "objective", "") or "").strip() + success_predicate = str(contract.get("success_predicate", "")).strip() + + verified = [str(getattr(fact, "value", "")).strip() for fact in getattr(mission, "verified_facts", [])] + validation_failures = [str(v).strip() for v in getattr(mission, "validation_failures", [])] + strongest_evidence = _dedupe( + verified + + validation_failures + + [str(getattr(runner, "_last_validation_summary", "")).strip()] + + [str(getattr(mission, "last_failed_summary", "")).strip()], + limit=5, + ) + + open_hypotheses = list(getattr(mission, "open_hypotheses", [])) + current_hypothesis = "" + rejected: list[str] = [] + for hypothesis in open_hypotheses: + statement = str(getattr(hypothesis, "statement", "")).strip() + status = str(getattr(hypothesis, "status", "")).strip().lower() + if not statement: + continue + if status in {"open", "active", "current"} and not current_hypothesis: + current_hypothesis = statement + elif status in {"rejected", "invalid", "discarded"}: + rejected.append(statement) + if not current_hypothesis: + current_hypothesis = ( + "A focused patch in current scope should satisfy the success predicate." + if success_predicate + else "Current scoped fix should resolve the task objective." + ) + + plan_files = [str(path) for path in getattr(getattr(runner, "_execution_plan", None), "relevant_files", [])] + in_scope = _dedupe( + _filter_model_facing_paths(list(getattr(mission, "intended_targets", [])) + plan_files), + limit=8, + ) + changed = _dedupe(_filter_model_facing_paths(list(getattr(mission, "changed_files", []))), limit=8) + + no_go_paths = [str(v).strip() for v in contract.get("no_go_paths", []) if str(v).strip()] + out_of_scope = _dedupe(_filter_model_facing_paths(no_go_paths), limit=8) + + last_verification_result = str(getattr(runner, "_last_validation_summary", "")).strip() + unresolved_blocker = str(getattr(mission, "last_failed_summary", "")).strip() + if not unresolved_blocker and validation_failures: + unresolved_blocker = validation_failures[0] + + next_best_action = "" + next_action_reason = "" + pending_verification = str(getattr(runner, "_pending_verification", "")).strip() + if pending_verification: + next_best_action = "Review latest verification output and apply one bounded repair." + next_action_reason = "Pending verification produced fresh failure evidence." + elif unresolved_blocker: + next_best_action = "Address unresolved blocker in the highest-signal in-scope file." + next_action_reason = "Latest run is still blocked." + else: + next_best_action = "Run verification against intended targets." + next_action_reason = "Need confirmation against the success predicate." + + pinned_constraints = _dedupe( + [f"Success predicate: {success_predicate}" if success_predicate else ""] + + [f"No-go: {path}" for path in no_go_paths[:4]] + + ["Prefer surgical patch."] + + ([f"Stay within preferred targets: {', '.join(contract.get('preferred_targets', [])[:4])}"] if contract.get("preferred_targets") else []), + limit=8, + ) + + return ExecutionMemento( + objective=objective, + current_subgoal=str(getattr(mission, "plan_summary", "")).strip() or objective, + current_step_id=str(getattr(mission, "current_step_id", "")).strip() or "localization", + strongest_evidence=strongest_evidence, + current_hypothesis=current_hypothesis, + rejected_hypotheses=_dedupe(rejected, limit=5), + in_scope_files=in_scope, + out_of_scope_files=out_of_scope, + changed_files=changed, + last_action=str(getattr(mission, "last_failed_command", "")).strip() or "Updated execution state.", + last_verification_result=last_verification_result or "No verification result recorded.", + unresolved_blocker=unresolved_blocker, + next_best_action=next_best_action, + next_action_reason=next_action_reason, + pinned_constraints=pinned_constraints, + success_predicate=success_predicate, + turn_index=int(getattr(runner, "_current_turn_index", 0) or 0), + 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) + json_path = mission_dir / "execution_memento.json" + payload = asdict(memento) + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + (mission_dir / "execution_memento.md").write_text(render_execution_memento_for_model(memento) + "\n", encoding="utf-8") + return json_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")) + return ExecutionMemento(**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]], + "Out of scope:", + *[f"- {line}" for line in memento.out_of_scope_files[:8]], + "Changed:", + *[f"- {line}" for line in memento.changed_files[:8]], + f"Last action: {memento.last_action}", + 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: + lines = ["LOCAL EVIDENCE"] + last_validation = str(getattr(runner, "_last_validation_summary", "")).strip() + if last_validation: + lines.append(f"- verification: {last_validation}") + last_failed = str(getattr(getattr(runner, "_mission_state", None), "last_failed_summary", "")).strip() + if last_failed: + lines.append(f"- last failure: {last_failed[:180]}") + pending = str(getattr(runner, "_pending_verification", "")).strip() + if pending: + compact = " ".join(pending.splitlines()) + lines.append(f"- pending verification: {compact[:220]}") + if len(lines) == 1: + return "" + 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..83dc3291 100644 --- a/villani_code/state.py +++ b/villani_code/state.py @@ -39,6 +39,12 @@ 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, + 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 @@ -1586,13 +1592,27 @@ def _ensure_mission(self, instruction: str) -> None: def _update_mission_state(self, **fields: Any) -> None: if self._mission_state is None: return + changed_field_names = 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() + changed_field_names.add("changed_files") save_mission_state(self.repo, self._mission_state) + memento_boundaries = { + "current_step_id", + "changed_files", + "validation_failures", + "last_failed_command", + "last_failed_summary", + "intended_targets", + "plan_summary", + "status", + } + if changed_field_names & memento_boundaries: + self._refresh_execution_memento() if self._debug_recorder is not None: self._debug_recorder.record_mission_state_snapshot( self._mission_state.to_dict(), @@ -1600,20 +1620,48 @@ def _update_mission_state(self, **fields: Any) -> None: turn_index=self._current_turn_index if isinstance(self._current_turn_index, int) else None, ) + def _refresh_execution_memento(self) -> None: + if self._mission_state is None: + return + 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) + def _inject_projected_context(self, messages: list[dict[str, Any]]) -> None: if not self._mission_state or self.benchmark_config.enabled: return if len(messages) <= 1: return if any( - "Mission context packet:" in str(block.get("text", "")) + "EXECUTION STATE" in str(block.get("text", "")) for msg in messages for block in msg.get("content", []) if isinstance(block, dict) ): return - packet = build_model_context_packet(self) - rendered = render_model_context_packet(packet) + memento = build_execution_memento(self) + required_present = all( + [ + 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 required_present: + rendered = render_execution_memento_for_model(memento) + evidence = build_local_evidence_block(self) + injected = rendered if not evidence else f"{rendered}\n\n{evidence}" + 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) + else: + packet = build_model_context_packet(self) + rendered = render_model_context_packet(packet) from villani_code import state_runtime state_runtime.prepend_text_to_latest_safe_user_message(messages, rendered) diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index 9e03824d..637e42cf 100644 --- a/villani_code/state_runtime.py +++ b/villani_code/state_runtime.py @@ -375,6 +375,11 @@ 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 getattr(runner, "villani_mode", False): + prepared = _trim_regular_runner_transcript_tail(prepared, keep_units=4) + inject = getattr(runner, "_inject_projected_context", None) + if callable(inject): + inject(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 +398,54 @@ def prepare_messages_for_model(runner: Any, messages: list[dict[str, Any]]) -> l return prepared +def _trim_regular_runner_transcript_tail( + messages: list[dict[str, Any]], + *, + keep_units: int = 4, +) -> list[dict[str, Any]]: + if len(messages) <= 8: + return messages + system_messages = [message for message in messages if message.get("role") == "system"] + conversation = [message for message in messages if message.get("role") != "system"] + if not conversation: + return messages + prefix: list[dict[str, Any]] = [] + idx = 0 + while idx < len(conversation) and conversation[idx].get("role") == "user": + prefix.append(conversation[idx]) + idx += 1 + units: list[list[dict[str, Any]]] = [] + i = idx + while i < len(conversation): + current = conversation[i] + content = current.get("content", []) + if ( + current.get("role") == "assistant" + and isinstance(content, list) + and any(isinstance(block, dict) and block.get("type") == "tool_use" for block in content) + and i + 1 < len(conversation) + and conversation[i + 1].get("role") == "user" + and isinstance(conversation[i + 1].get("content", []), list) + and conversation[i + 1].get("content", []) + and all( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in conversation[i + 1].get("content", []) + ) + ): + units.append([current, conversation[i + 1]]) + i += 2 + continue + units.append([current]) + i += 1 + tail: list[dict[str, Any]] = [] + for unit in units[-keep_units:]: + tail.extend(unit) + kept = system_messages + prefix + tail + if len(kept) >= len(messages): + return messages + return kept + + def validate_anthropic_tool_sequence(messages: list[dict[str, Any]]) -> None: for index, message in enumerate(messages): if message.get("role") != "assistant": @@ -830,6 +883,9 @@ def _build_compact_validation_summary( if emitted_fingerprint == getattr(runner, "_last_emitted_validation_fingerprint", ""): return "" runner._last_emitted_validation_fingerprint = emitted_fingerprint + refresh = getattr(runner, "_refresh_execution_memento", None) + if callable(refresh): + refresh() return ( "\n" f"last_validation_target: {target or 'none'}\n" @@ -1330,6 +1386,9 @@ def run_post_execution_validation(runner: Any, changed_files: list[str]) -> str: runner._mission_state.validation_failures = [] runner._mission_state.last_checkpoint_id = checkpoint.checkpoint_id save_mission_state(runner.repo, runner._mission_state) + refresh = getattr(runner, "_refresh_execution_memento", None) + if callable(refresh): + refresh() return "Validation: passed." outcome = execute_repair_loop( @@ -1358,6 +1417,9 @@ def run_post_execution_validation(runner: Any, changed_files: list[str]) -> str: runner._mission_state.validation_failures = [] runner._mission_state.last_checkpoint_id = checkpoint.checkpoint_id save_mission_state(runner.repo, runner._mission_state) + refresh = getattr(runner, "_refresh_execution_memento", None) + if callable(refresh): + refresh() return outcome.message checkpoint = runner._context_governance.create_checkpoint(inventory, str(getattr(plan, "task_goal", "")), ["repair attempts exhausted"]) @@ -1377,4 +1439,7 @@ def run_post_execution_validation(runner: Any, changed_files: list[str]) -> str: runner._mission_state.last_failed_summary = outcome.message runner._mission_state.last_checkpoint_id = checkpoint.checkpoint_id save_mission_state(runner.repo, runner._mission_state) + refresh = getattr(runner, "_refresh_execution_memento", None) + if callable(refresh): + refresh() return outcome.message