diff --git a/strix/core/execution.py b/strix/core/execution.py index 747aad64..46e07aac 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -30,6 +30,7 @@ from strix.core.inputs import child_initial_input from strix.core.sessions import ( enforce_image_budget, open_agent_session, + seed_initial_input, strip_all_images_from_session, ) from strix.llm.compaction import is_context_overflow, maybe_compact @@ -116,6 +117,17 @@ def _transient_model_retry_delay(attempt: int) -> float: return min(delay, _TRANSIENT_MODEL_RETRY_MAX_DELAY_S) +async def _seed_and_prepare_first_input( + session: Session | None, initial_input: Any, *, start_parked: bool +) -> Any: + """Persist the opening input up front so it survives a first-turn crash.""" + if initial_input and session is not None and not start_parked: + with contextlib.suppress(Exception): + if await seed_initial_input(session, initial_input): + return [] + return initial_input + + async def run_agent_loop( *, agent: Any, @@ -138,6 +150,10 @@ async def run_agent_loop( ) result: RunResultBase | None = None + first_cycle_input = await _seed_and_prepare_first_input( + session, initial_input, start_parked=start_parked + ) + budget_stopped = coordinator.budget_stopped reserve_stopped = coordinator.reserve_stopped if budget_stopped: @@ -157,7 +173,7 @@ async def run_agent_loop( agent, coordinator, agent_id, - input_data=initial_input, + input_data=first_cycle_input, run_config=run_config, context=context, max_turns=max_turns, @@ -170,7 +186,7 @@ async def run_agent_loop( agent, coordinator, agent_id, - initial_input=initial_input, + initial_input=first_cycle_input, run_config=run_config, context=context, max_turns=max_turns, diff --git a/strix/core/sessions.py b/strix/core/sessions.py index 2bb83982..8879d359 100644 --- a/strix/core/sessions.py +++ b/strix/core/sessions.py @@ -7,6 +7,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from weakref import WeakKeyDictionary +from agents.items import ItemHelpers from agents.memory import SQLiteSession @@ -26,6 +27,18 @@ def open_agent_session(agent_id: str, path: Path) -> SQLiteSession: return SQLiteSession(session_id=agent_id, db_path=path) +async def seed_initial_input(session: Session, initial_input: Any) -> bool: + """Commit an agent's opening identity/task input before its first run cycle.""" + items = ItemHelpers.input_to_new_input_list(initial_input) + if not items: + return False + async with session_write_lock(session): + if await session.get_items(): + return False + await session.add_items(items) + return True + + _IMAGE_REJECTED_TEXT = "[image rejected by the model]" _IMAGE_ELIDED_TEXT = "[older screenshot elided to bound context memory]" _INHERITED_IMAGE_TEXT = "[screenshot omitted from inherited context]" diff --git a/tests/test_execution.py b/tests/test_execution.py index 50675b41..0c029e55 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -17,6 +17,7 @@ from strix.core.execution import ( _notify_parent_on_terminal, _notify_root_on_budget_reserve, ) +from strix.core.sessions import seed_initial_input from strix.tools.finish.tool import finish_scan @@ -592,3 +593,59 @@ async def test_run_cycle_parked_parks_instead_of_raising( assert result is None assert coordinator.statuses["root"] == "failed" assert coordinator.errors["root"] == "unexpected explosion" + + +@pytest.mark.asyncio +async def test_seed_initial_input_persists_and_is_idempotent(tmp_path: Any) -> None: + session = SQLiteSession("child", tmp_path / "agents.db") + identity = [{"role": "user", "content": "You are agent recon (abc); do X."}] + + assert await seed_initial_input(session, identity) is True + assert len(await session.get_items()) == 1 + + # A populated session is left untouched (no duplicate identity message). + assert await seed_initial_input(session, identity) is False + assert len(await session.get_items()) == 1 + + assert await seed_initial_input(session, []) is False + session.close() + + +@pytest.mark.asyncio +async def test_run_agent_loop_seeds_identity_before_first_cycle( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + coordinator = AgentCoordinator() + await coordinator.register("child", "recon", parent_id="root") + session = SQLiteSession("child", tmp_path / "agents.db") + + captured: dict[str, Any] = {} + + async def _crash_first_turn(*_args: Any, **kwargs: Any) -> Any: + captured["input_data"] = kwargs.get("input_data") + captured["items_at_start"] = await session.get_items() + raise RuntimeError("first-turn crash") + + monkeypatch.setattr(execution, "_run_cycle", _crash_first_turn) + + identity = [{"role": "user", "content": "You are agent recon (abc); maintain your identity."}] + with pytest.raises(RuntimeError, match="first-turn crash"): + await execution.run_agent_loop( + agent=object(), + initial_input=identity, + run_config=None, # type: ignore[arg-type] + context={"agent_id": "child", "parent_id": "root"}, + max_turns=5, + coordinator=coordinator, + agent_id="child", + interactive=False, + session=session, + ) + + # The first cycle ran with an empty input against the pre-seeded session. + assert captured["input_data"] == [] + assert captured["items_at_start"] + # The identity/task survives the first-turn crash, so a revival can resume it. + stored = await session.get_items() + assert any("recon" in str(cast("dict[str, Any]", i).get("content", "")) for i in stored) + session.close()