diff --git a/strix/core/execution.py b/strix/core/execution.py index 4bcd31d885..2df8ffb5c0 100644 --- a/strix/core/execution.py +++ b/strix/core/execution.py @@ -24,6 +24,7 @@ from strix.config import codex from strix.core.hooks import ( + WRAP_UP_AFTER_TURNS_KEY, BudgetExceededError, BudgetPausedError, SubagentBudgetReservedError, @@ -39,6 +40,7 @@ ) from strix.llm import request_log from strix.llm.compaction import is_context_overflow, maybe_compact +from strix.skills import skill_wrap_up_after_turns if TYPE_CHECKING: @@ -434,6 +436,7 @@ async def spawn_child_agent( task=task, parent_history=parent_history, ), + wrap_up_after_turns=skill_wrap_up_after_turns(skills), event_sink=event_sink, hooks=hooks, ) @@ -512,6 +515,7 @@ async def respawn_subagents( task=str(md.get("task", "")), initial_input=[], start_parked=start_parked, + wrap_up_after_turns=skill_wrap_up_after_turns(child_skills), event_sink=event_sink, hooks=hooks, ) @@ -1163,6 +1167,7 @@ async def _start_child_runner( task: str, initial_input: Any, start_parked: bool = False, + wrap_up_after_turns: int | None = None, event_sink: StreamEventSink | None = None, hooks: RunHooks[dict[str, Any]] | None = None, ) -> None: @@ -1174,6 +1179,7 @@ async def _start_child_runner( child_ctx["agent_id"] = child_id child_ctx["parent_id"] = parent_id child_ctx["task"] = task + child_ctx[WRAP_UP_AFTER_TURNS_KEY] = wrap_up_after_turns async def _child_loop() -> None: # A budget stop is a clean scan-wide shutdown, not a child failure: the diff --git a/strix/core/hooks.py b/strix/core/hooks.py index 13fc98f988..6b50ba1c45 100644 --- a/strix/core/hooks.py +++ b/strix/core/hooks.py @@ -22,6 +22,9 @@ LLM_TURN_KEY = "llm_turn" +# Set on a sub-agent's context from its skills (see ``skill_wrap_up_after_turns``). +WRAP_UP_AFTER_TURNS_KEY = "wrap_up_after_turns" +_WRAP_UP_REPEAT_TURNS = 5 # ``BudgetPolicy`` decides what happens when the accumulated LLM cost reaches # ``max_budget_usd``. @@ -35,6 +38,7 @@ # same call when the operator raises the limit or resumes. __all__ = [ "LLM_TURN_KEY", + "WRAP_UP_AFTER_TURNS_KEY", "BudgetExceededError", "BudgetPausedError", "BudgetPolicy", @@ -207,6 +211,7 @@ async def on_llm_start( context.context[LLM_TURN_KEY] = int(context.context.get(LLM_TURN_KEY, 0)) + 1 try: self._maybe_warn_turns(context, input_items) + self._maybe_nudge_wrap_up(context, input_items) self._maybe_warn_budget(context, input_items) except Exception: logger.exception("budget/turn warning injection failed") @@ -270,6 +275,26 @@ def _maybe_warn_turns( ) input_items.append({"role": "user", "content": content}) + def _maybe_nudge_wrap_up( + self, + context: RunContextWrapper[dict[str, Any]], + input_items: list[TResponseInputItem], + ) -> None: + """A soft nudge, never a stop: at the turn a skill asks for, then every few turns.""" + after = context.context.get(WRAP_UP_AFTER_TURNS_KEY) + requests = getattr(getattr(context, "usage", None), "requests", None) + if not isinstance(after, int) or not isinstance(requests, int): + return + turns_used = requests + 1 + if turns_used < after or (turns_used - after) % _WRAP_UP_REPEAT_TURNS: + return + content = ( + f"[NOTICE] You have used {turns_used} turns. Wrap up soon: take only the steps " + "you still need to answer your task, then call agent_finish with what you have, " + "listing anything unresolved in open_items instead of tracing it further." + ) + input_items.append({"role": "user", "content": content}) + def _maybe_warn_budget( self, context: RunContextWrapper[dict[str, Any]], diff --git a/strix/skills/__init__.py b/strix/skills/__init__.py index 1e66fae0b4..fd53c60c94 100644 --- a/strix/skills/__init__.py +++ b/strix/skills/__init__.py @@ -270,6 +270,19 @@ def _candidate_skill_files(skill_name: str) -> list[Path]: return _bare_skill_files(skill_name) +def skill_wrap_up_after_turns(skill_names: list[str]) -> int | None: + """Smallest ``wrap_up_after_turns`` set in these skills' frontmatter, if any.""" + turns = [] + for skill_name in skill_names: + candidates = _candidate_skill_files(skill_name) + if len(candidates) != 1: + continue + value = _read_skill_metadata(candidates[0]).get("wrap_up_after_turns", "") + if value.isdigit() and int(value) > 0: + turns.append(int(value)) + return min(turns, default=None) + + def load_skills(skill_names: list[str], *, supports_images: bool = True) -> dict[str, str]: """Load skill markdown bodies (frontmatter stripped) by name.