feat(agents): soft wrap-up nudge for sub-agents whose skills set wrap_up_after_turns

This commit is contained in:
Ian 2026-10-20 17:50:04 -04:00
parent 913289d657
commit d5953670d9
3 changed files with 44 additions and 0 deletions

View file

@ -24,6 +24,7 @@ from openai import (
from strix.config import codex
from strix.core.hooks import (
WRAP_UP_AFTER_TURNS_KEY,
BudgetExceededError,
BudgetPausedError,
SubagentBudgetReservedError,
@ -39,6 +40,7 @@ from strix.core.sessions import (
)
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

View file

@ -22,6 +22,9 @@ logger = logging.getLogger(__name__)
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 @@ LLM_TURN_KEY = "llm_turn"
# 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 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
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 @@ class ReportUsageHooks(RunHooks[dict[str, Any]]):
)
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]],

View file

@ -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.