Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions strix/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from strix.config import codex
from strix.core.hooks import (
WRAP_UP_AFTER_TURNS_KEY,
BudgetExceededError,
BudgetPausedError,
SubagentBudgetReservedError,
Expand All @@ -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:
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions strix/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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]],
Expand Down
13 changes: 13 additions & 0 deletions strix/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading