mirror of
https://github.com/usestrix/strix.git
synced 2026-08-20 18:38:57 +02:00
fix: persist agent identity/task before first run cycle so it survives a first-turn crash
This commit is contained in:
+18
-2
@@ -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,
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user