Compare commits

...
6 changed files with 51 additions and 6 deletions

No files matched your search

+2 -1
View File
@@ -44,7 +44,7 @@ INTERACTIVE BEHAVIOR:
- If you end a turn on plain text and the nudge arrives, your words already reached the user. Do not repeat them: call wait_for_user.
- You can narrate while you work — plain text is shown to the user as you go. Narrating is free; wait_for_user is specifically the act of WAITING for the user, so do not call it just to give a status update.
- Respond naturally when the user asks questions or gives instructions.
- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and wait_for_user only when you genuinely need the user.
- While actively working on a task, every turn should carry a tool call (independent calls can go in the same turn) — use think to plan, the appropriate tool to act, and wait_for_user only when you genuinely need the user.
- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, write it and call wait_for_user.
{% else %}
AUTONOMOUS BEHAVIOR:
@@ -55,6 +55,7 @@ AUTONOMOUS BEHAVIOR:
- There is no user attached to this run, so there is nobody to ask and nothing to yield to. If there is nothing left to execute: do NOT send filler/repetitive text — either call wait_for_agents (only if you are genuinely expecting another agent to message you) or finish your work (subagents: agent_finish; root: finish_scan)
- While the agent loop is running, almost every output MUST be a tool call. Do NOT send plain text messages; act via tools. If waiting on another agent, use wait_for_agents; when done, use agent_finish (subagents) or finish_scan (root)
- A text-only turn does nothing: it neither ends the run nor yields — it just wastes a turn and forces a retry. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY way to terminate, and the report flows through them. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead.
- Read-only calls can go together in one turn: when several file reads or searches don't depend on each other's output, issue them as separate tool calls in the same turn rather than one per turn. Anything else — writing files, running commands that change state, sending requests, creating or messaging agents, reporting, or finishing — goes alone in its own turn.
{% endif %}
</communication_rules>
+8 -2
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,
)
@@ -1004,7 +1008,7 @@ async def _append_tool_required_message(
message = (
"Your previous message ended a turn without a tool call. Plain text never ends "
"execution and never hands control to the user: it is shown to the user, and the "
"run continues. Continue immediately and call exactly one tool. "
"run continues. Continue immediately and call a tool. "
"If you have nothing to do until the user replies, call wait_for_user; your "
"text already reached them, so do not repeat it. "
"If you are blocked waiting for another agent, call wait_for_agents. "
@@ -1016,7 +1020,7 @@ async def _append_tool_required_message(
message = (
"Your previous response ended the autonomous run without a lifecycle tool "
"call. That is invalid in non-interactive mode; plain text final answers are "
"ignored. Continue immediately and call exactly one tool. "
"ignored. Continue immediately and call a tool. "
f"If your work is complete, call {finish_tool}. "
"If you are blocked waiting for another agent, call wait_for_agents. "
"Otherwise use the appropriate execution or planning tool. "
@@ -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
+25
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]],
+1 -1
View File
@@ -255,7 +255,7 @@ def make_model_settings(
) -> ModelSettings:
headers = _request_headers(model_name, extra_headers)
model_settings = ModelSettings(
parallel_tool_calls=False if has_tools else None,
parallel_tool_calls=True if has_tools else None,
retry=DEFAULT_MODEL_RETRY,
include_usage=True,
extra_args=request_timeout_extra_args(request_timeout),
+13
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.
+2 -2
View File
@@ -310,8 +310,8 @@ def test_make_model_settings_forces_required_for_anyllm_routed_openai_model() ->
assert settings.tool_choice == "required"
def test_make_model_settings_disables_parallel_tool_calls_by_default() -> None:
assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is False
def test_make_model_settings_enables_parallel_tool_calls_by_default() -> None:
assert make_model_settings("none", model_name="gpt-4o").parallel_tool_calls is True
def test_make_model_settings_omits_parallel_tool_calls_without_tools() -> None: