mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d97dc1b2ef | ||
|
|
8b464dae5d | ||
|
|
b7bf52c468 | ||
|
|
ceff3b5408 | ||
|
|
44bb3abdf8 | ||
|
|
5726c2d4ef | ||
|
|
69a60f3b7a |
+11
-3
@@ -23,7 +23,7 @@ from strix.tools.agents_graph.tools import (
|
|||||||
send_message_to_agent,
|
send_message_to_agent,
|
||||||
stop_agent,
|
stop_agent,
|
||||||
view_agent_graph,
|
view_agent_graph,
|
||||||
wait_for_message,
|
wait_for_agents,
|
||||||
)
|
)
|
||||||
from strix.tools.finish.tool import finish_scan
|
from strix.tools.finish.tool import finish_scan
|
||||||
from strix.tools.load_skill.tool import load_skill
|
from strix.tools.load_skill.tool import load_skill
|
||||||
@@ -49,6 +49,7 @@ from strix.tools.reporting.tool import (
|
|||||||
get_report,
|
get_report,
|
||||||
list_reports,
|
list_reports,
|
||||||
)
|
)
|
||||||
|
from strix.tools.respond.tool import respond_to_user
|
||||||
from strix.tools.thinking.tool import think
|
from strix.tools.thinking.tool import think
|
||||||
from strix.tools.todo.tools import (
|
from strix.tools.todo.tools import (
|
||||||
create_todo,
|
create_todo,
|
||||||
@@ -345,6 +346,10 @@ def _make_shell_configurator(*, chat_completions: bool) -> Any:
|
|||||||
return configure
|
return configure
|
||||||
|
|
||||||
|
|
||||||
|
# Tools that hand control away by parking the agent rather than ending the scan.
|
||||||
|
_PARKING_TOOLS: frozenset[str] = frozenset({"respond_to_user", "wait_for_agents"})
|
||||||
|
|
||||||
|
|
||||||
def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
|
def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
|
||||||
if tool_name == "agent_finish":
|
if tool_name == "agent_finish":
|
||||||
completion_key = "agent_completed"
|
completion_key = "agent_completed"
|
||||||
@@ -363,7 +368,7 @@ def _lifecycle_tool_completed(tool_name: str, output: Any) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _wait_tool_parked(tool_name: str, output: Any) -> bool:
|
def _wait_tool_parked(tool_name: str, output: Any) -> bool:
|
||||||
if tool_name != "wait_for_message" or not isinstance(output, str):
|
if tool_name not in _PARKING_TOOLS or not isinstance(output, str):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(output)
|
parsed = json.loads(output)
|
||||||
@@ -425,7 +430,7 @@ _BASE_TOOLS: tuple[Tool, ...] = (
|
|||||||
scope_rules,
|
scope_rules,
|
||||||
view_agent_graph,
|
view_agent_graph,
|
||||||
send_message_to_agent,
|
send_message_to_agent,
|
||||||
wait_for_message,
|
wait_for_agents,
|
||||||
create_agent,
|
create_agent,
|
||||||
stop_agent,
|
stop_agent,
|
||||||
)
|
)
|
||||||
@@ -509,6 +514,9 @@ def build_strix_agent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
|
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
|
||||||
|
if interactive:
|
||||||
|
# Yielding to the user is only meaningful when one is attached.
|
||||||
|
agent_tools.append(respond_to_user)
|
||||||
if is_root:
|
if is_root:
|
||||||
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
|
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -31,28 +31,26 @@ INTER-AGENT MESSAGES:
|
|||||||
|
|
||||||
{% if interactive %}
|
{% if interactive %}
|
||||||
INTERACTIVE BEHAVIOR:
|
INTERACTIVE BEHAVIOR:
|
||||||
- You are in an interactive conversation with a user
|
- You are in an interactive conversation with a user.
|
||||||
- CRITICAL: A message WITHOUT a tool call IMMEDIATELY STOPS your entire execution and waits for user input. This is a HARD SYSTEM CONSTRAINT, not a suggestion.
|
- HOW EXECUTION ENDS: your turn ends ONLY when you make an explicit lifecycle tool call. Plain text NEVER ends your turn and NEVER hands control to the user — text is shown to the user, and then execution continues.
|
||||||
- Statements like "Planning the assessment..." or "I'll now scan..." or "Starting with..." WITHOUT a tool call will HALT YOUR WORK COMPLETELY. The system interprets no-tool-call as "I'm done, waiting for the user."
|
- To answer the user and hand control back, call respond_to_user. It delivers your message AND parks you for their reply in one call, so there is no way to answer and then forget to stop. This is the ONLY way to yield to the user.
|
||||||
- If you want to plan, call the think tool. If you want to act, call the appropriate tool. There is NO valid reason to output text without a tool call while working on a task.
|
- To wait on another AGENT (a child's report, a peer's reply), call wait_for_agents. That is not a way to reach the user.
|
||||||
- The ONLY time you may send a message without a tool call is when you are genuinely DONE and presenting final results, or when you NEED the user to answer a question before continuing.
|
- To end the whole engagement, call the lifecycle tool: finish_scan (root) or agent_finish (subagent).
|
||||||
- EVERY message while working MUST contain exactly one tool call — this is what keeps execution moving. No tool call = execution stops.
|
- A turn that ends with plain text and no tool call does NOT stop you: the system nudges you to continue and will re-run you. Do not rely on going silent to pause — it will not pause you.
|
||||||
- You may include brief explanatory text BEFORE the tool call
|
- Answering a user question: put the answer in respond_to_user's message. Do not write the answer as plain text and then fall silent — that does not reach a stopping point, it just triggers a continuation nudge.
|
||||||
- Respond naturally when the user asks questions or gives instructions
|
- You may include brief explanatory text before a tool call, and you can narrate while you work — plain text is shown to the user as you go. Narrating is free; respond_to_user is specifically the act of WAITING for the user, so do not call it just to give a status update.
|
||||||
- For simple conversation, acknowledgements, or direct questions that you can answer from current context, reply in plain text and stop. Do NOT call think just to prepare wording.
|
- Respond naturally when the user asks questions or gives instructions.
|
||||||
- If you use a tool to answer a user question (for example list_todos, view_agent_graph, or a file read), then after the tool result arrives, provide the answer in plain text and stop unless the user explicitly asked you to continue working.
|
- While actively working on a task, every turn should carry exactly one tool call — use think to plan, the appropriate tool to act, and respond_to_user only when you genuinely need the user.
|
||||||
- Never loop through think or other tools just to prepare, polish, confirm, or announce a final answer. Once you know the answer, say it.
|
- Never loop through think or other tools just to prepare, polish, confirm, or announce an answer. Once you know the answer, send it with respond_to_user.
|
||||||
- NEVER send empty messages — if you have nothing to do or say, call the wait_for_message tool
|
|
||||||
- If you catch yourself about to describe multiple steps without a tool call, STOP and call the think tool instead
|
|
||||||
{% else %}
|
{% else %}
|
||||||
AUTONOMOUS BEHAVIOR:
|
AUTONOMOUS BEHAVIOR:
|
||||||
- Work autonomously by default
|
- Work autonomously by default
|
||||||
- You should NOT ask for user input or confirmation - you should always proceed with your task autonomously.
|
- You should NOT ask for user input or confirmation - you should always proceed with your task autonomously.
|
||||||
- Minimize user messaging: avoid redundancy and repetition; consolidate updates into a single concise message
|
- Minimize user messaging: avoid redundancy and repetition; consolidate updates into a single concise message
|
||||||
- NEVER send an empty or blank message. If you have no content to output or need to wait (for user input, subagent results, or any other reason), you MUST call the wait_for_message tool (or another appropriate tool) instead of emitting an empty response.
|
- NEVER send an empty or blank message. If you have no content to output or need to wait for subagent results, you MUST call the wait_for_agents tool (or another appropriate tool) instead of emitting an empty response.
|
||||||
- If there is nothing to execute and no user query to answer any more: do NOT send filler/repetitive text — either call wait_for_message or finish your work (subagents: agent_finish; root: finish_scan)
|
- 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 idle, use wait_for_message; when done, use agent_finish (subagents) or finish_scan (root)
|
- 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 — even one — IMMEDIATELY ends the scan/run with no report written. The lifecycle tools (``finish_scan`` for root, ``agent_finish`` for subagents) are the ONLY valid way to terminate. If you find yourself wanting to say "Done!" or "Scan complete" without a tool call, call the lifecycle tool instead — the report and termination signal both flow through it.
|
- 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.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</communication_rules>
|
</communication_rules>
|
||||||
|
|
||||||
|
|||||||
+61
-1
@@ -24,6 +24,11 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
Status = Literal["running", "waiting", "completed", "stopped", "crashed", "failed", "budget_paused"]
|
||||||
|
|
||||||
|
# Why an agent parked. The user can message any agent, so this - not the agent's
|
||||||
|
# position in the tree - decides whether waiting is bounded: only an agent waiting
|
||||||
|
# on other agents is re-checked on a timer.
|
||||||
|
WaitKind = Literal["user", "agents", "stalled"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AgentRuntime:
|
class AgentRuntime:
|
||||||
@@ -46,6 +51,9 @@ class AgentCoordinator:
|
|||||||
self.metadata: dict[str, dict[str, Any]] = {}
|
self.metadata: dict[str, dict[str, Any]] = {}
|
||||||
self.pending_counts: dict[str, int] = {}
|
self.pending_counts: dict[str, int] = {}
|
||||||
self.errors: dict[str, str] = {}
|
self.errors: dict[str, str] = {}
|
||||||
|
self.recovery_counts: dict[str, int] = {}
|
||||||
|
self.idle_resume_counts: dict[str, int] = {}
|
||||||
|
self.wait_kinds: dict[str, WaitKind] = {}
|
||||||
self.runtimes: dict[str, AgentRuntime] = {}
|
self.runtimes: dict[str, AgentRuntime] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._snapshot_path: Path | None = None
|
self._snapshot_path: Path | None = None
|
||||||
@@ -181,12 +189,58 @@ class AgentCoordinator:
|
|||||||
if agent_id in self.statuses:
|
if agent_id in self.statuses:
|
||||||
self.statuses[agent_id] = "running"
|
self.statuses[agent_id] = "running"
|
||||||
self.errors.pop(agent_id, None)
|
self.errors.pop(agent_id, None)
|
||||||
|
self.wait_kinds.pop(agent_id, None)
|
||||||
self.runtimes.setdefault(agent_id, AgentRuntime()).user_wake_required = False
|
self.runtimes.setdefault(agent_id, AgentRuntime()).user_wake_required = False
|
||||||
await self._maybe_snapshot()
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
async def park_waiting(self, agent_id: str) -> None:
|
async def park_waiting(self, agent_id: str, *, wait_kind: WaitKind) -> None:
|
||||||
|
"""Park an agent, recording what it is waiting on so the driver can time it."""
|
||||||
|
async with self._lock:
|
||||||
|
if agent_id in self.statuses:
|
||||||
|
self.wait_kinds[agent_id] = wait_kind
|
||||||
await self.set_status(agent_id, "waiting")
|
await self.set_status(agent_id, "waiting")
|
||||||
|
|
||||||
|
async def wait_kind_of(self, agent_id: str) -> WaitKind | None:
|
||||||
|
async with self._lock:
|
||||||
|
return self.wait_kinds.get(agent_id)
|
||||||
|
|
||||||
|
async def record_recovery(self, agent_id: str) -> int:
|
||||||
|
"""Count a turn that ended without a lifecycle tool call; return the new total.
|
||||||
|
|
||||||
|
Persisted so a resumed agent cannot earn a fresh nudge budget on every
|
||||||
|
auto-resume and loop forever.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
count = self.recovery_counts.get(agent_id, 0) + 1
|
||||||
|
self.recovery_counts[agent_id] = count
|
||||||
|
await self._maybe_snapshot()
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def reset_recovery(self, agent_id: str) -> None:
|
||||||
|
"""Clear the nudge budget after real progress (new message or a lifecycle tool)."""
|
||||||
|
async with self._lock:
|
||||||
|
if self.recovery_counts.pop(agent_id, None) is None:
|
||||||
|
return
|
||||||
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
|
async def record_idle_resume(self, agent_id: str) -> int:
|
||||||
|
"""Count an auto-resume that no message triggered; return the new total.
|
||||||
|
|
||||||
|
An agent that parks again after every auto-resume would otherwise burn a
|
||||||
|
model turn per timeout for the rest of the scan.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
count = self.idle_resume_counts.get(agent_id, 0) + 1
|
||||||
|
self.idle_resume_counts[agent_id] = count
|
||||||
|
await self._maybe_snapshot()
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def reset_idle_resumes(self, agent_id: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if self.idle_resume_counts.pop(agent_id, None) is None:
|
||||||
|
return
|
||||||
|
await self._maybe_snapshot()
|
||||||
|
|
||||||
async def set_status(
|
async def set_status(
|
||||||
self, agent_id: str, status: Status | str, *, error: str | None = None
|
self, agent_id: str, status: Status | str, *, error: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -397,6 +451,9 @@ class AgentCoordinator:
|
|||||||
"names": dict(self.names),
|
"names": dict(self.names),
|
||||||
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
"metadata": {aid: dict(md) for aid, md in self.metadata.items()},
|
||||||
"pending_counts": dict(self.pending_counts),
|
"pending_counts": dict(self.pending_counts),
|
||||||
|
"recovery_counts": dict(self.recovery_counts),
|
||||||
|
"idle_resume_counts": dict(self.idle_resume_counts),
|
||||||
|
"wait_kinds": dict(self.wait_kinds),
|
||||||
"mailboxes": {
|
"mailboxes": {
|
||||||
aid: [dict(m) for m in runtime.mailbox]
|
aid: [dict(m) for m in runtime.mailbox]
|
||||||
for aid, runtime in self.runtimes.items()
|
for aid, runtime in self.runtimes.items()
|
||||||
@@ -416,6 +473,9 @@ class AgentCoordinator:
|
|||||||
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
self.metadata = {aid: dict(md) for aid, md in snap.get("metadata", {}).items()}
|
||||||
self.pending_counts = dict(snap.get("pending_counts", {}))
|
self.pending_counts = dict(snap.get("pending_counts", {}))
|
||||||
self.errors = dict(snap.get("errors", {}))
|
self.errors = dict(snap.get("errors", {}))
|
||||||
|
self.recovery_counts = dict(snap.get("recovery_counts", {}))
|
||||||
|
self.idle_resume_counts = dict(snap.get("idle_resume_counts", {}))
|
||||||
|
self.wait_kinds = dict(snap.get("wait_kinds", {}))
|
||||||
mailboxes = snap.get("mailboxes", {})
|
mailboxes = snap.get("mailboxes", {})
|
||||||
if isinstance(mailboxes, dict):
|
if isinstance(mailboxes, dict):
|
||||||
for aid, msgs in mailboxes.items():
|
for aid, msgs in mailboxes.items():
|
||||||
|
|||||||
+182
-87
@@ -208,22 +208,8 @@ async def run_agent_loop(
|
|||||||
await coordinator.send(agent_id, _reserve_notice())
|
await coordinator.send(agent_id, _reserve_notice())
|
||||||
|
|
||||||
if not (start_parked and interactive):
|
if not (start_parked and interactive):
|
||||||
if interactive:
|
with contextlib.suppress(BudgetPausedError):
|
||||||
with contextlib.suppress(BudgetPausedError):
|
result = await _run_until_lifecycle(
|
||||||
result = await _run_cycle_parked(
|
|
||||||
agent,
|
|
||||||
coordinator,
|
|
||||||
agent_id,
|
|
||||||
input_data=first_cycle_input,
|
|
||||||
run_config=run_config,
|
|
||||||
context=context,
|
|
||||||
max_turns=max_turns,
|
|
||||||
session=session,
|
|
||||||
event_sink=event_sink,
|
|
||||||
hooks=hooks,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = await _run_noninteractive_until_lifecycle(
|
|
||||||
agent,
|
agent,
|
||||||
coordinator,
|
coordinator,
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -232,6 +218,7 @@ async def run_agent_loop(
|
|||||||
context=context,
|
context=context,
|
||||||
max_turns=max_turns,
|
max_turns=max_turns,
|
||||||
session=session,
|
session=session,
|
||||||
|
interactive=interactive,
|
||||||
event_sink=event_sink,
|
event_sink=event_sink,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
)
|
)
|
||||||
@@ -240,7 +227,7 @@ async def run_agent_loop(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
timeout = await _plain_waiting_timeout(coordinator, agent_id, context)
|
timeout = await _plain_waiting_timeout(coordinator, agent_id)
|
||||||
try:
|
try:
|
||||||
woke = await coordinator.wait_for_message(agent_id, timeout=timeout)
|
woke = await coordinator.wait_for_message(agent_id, timeout=timeout)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -254,7 +241,23 @@ async def run_agent_loop(
|
|||||||
await coordinator.set_status(agent_id, "stopped")
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||||
|
|
||||||
if not woke:
|
if woke:
|
||||||
|
# Real input is real progress, so the nudge budget starts over. A bare
|
||||||
|
# auto-resume is not: it must not hand a wedged agent a fresh budget.
|
||||||
|
await coordinator.reset_recovery(agent_id)
|
||||||
|
await coordinator.reset_idle_resumes(agent_id)
|
||||||
|
else:
|
||||||
|
idle_resumes = await coordinator.record_idle_resume(agent_id)
|
||||||
|
if idle_resumes >= _MAX_IDLE_AUTO_RESUMES:
|
||||||
|
logger.warning(
|
||||||
|
"agent %s auto-resumed %d times without hearing from anyone; "
|
||||||
|
"leaving it parked until a real message arrives",
|
||||||
|
agent_id,
|
||||||
|
idle_resumes,
|
||||||
|
)
|
||||||
|
await coordinator.park_waiting(agent_id, wait_kind="stalled")
|
||||||
|
await _notify_parent_on_stall(coordinator, agent_id)
|
||||||
|
continue
|
||||||
logger.info("agent %s reached its waiting timeout; auto-resuming", agent_id)
|
logger.info("agent %s reached its waiting timeout; auto-resuming", agent_id)
|
||||||
await coordinator.send(
|
await coordinator.send(
|
||||||
agent_id,
|
agent_id,
|
||||||
@@ -268,15 +271,16 @@ async def run_agent_loop(
|
|||||||
|
|
||||||
await coordinator.consume_pending(agent_id)
|
await coordinator.consume_pending(agent_id)
|
||||||
with contextlib.suppress(BudgetPausedError):
|
with contextlib.suppress(BudgetPausedError):
|
||||||
result = await _run_cycle_parked(
|
result = await _run_until_lifecycle(
|
||||||
agent,
|
agent,
|
||||||
coordinator,
|
coordinator,
|
||||||
agent_id,
|
agent_id,
|
||||||
input_data=[],
|
initial_input=[],
|
||||||
run_config=run_config,
|
run_config=run_config,
|
||||||
context=context,
|
context=context,
|
||||||
max_turns=max_turns,
|
max_turns=max_turns,
|
||||||
session=session,
|
session=session,
|
||||||
|
interactive=True,
|
||||||
event_sink=event_sink,
|
event_sink=event_sink,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
)
|
)
|
||||||
@@ -427,7 +431,10 @@ async def respawn_subagents(
|
|||||||
await coordinator.set_status(child_id, "crashed")
|
await coordinator.set_status(child_id, "crashed")
|
||||||
|
|
||||||
|
|
||||||
async def _run_noninteractive_until_lifecycle(
|
_INTERACTIVE_TOOL_RECOVERY_LIMIT = 3
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_until_lifecycle(
|
||||||
agent: Any,
|
agent: Any,
|
||||||
coordinator: AgentCoordinator,
|
coordinator: AgentCoordinator,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
@@ -437,14 +444,20 @@ async def _run_noninteractive_until_lifecycle(
|
|||||||
context: dict[str, Any],
|
context: dict[str, Any],
|
||||||
max_turns: int,
|
max_turns: int,
|
||||||
session: Session | None,
|
session: Session | None,
|
||||||
|
interactive: bool,
|
||||||
event_sink: StreamEventSink | None,
|
event_sink: StreamEventSink | None,
|
||||||
hooks: RunHooks[dict[str, Any]] | None,
|
hooks: RunHooks[dict[str, Any]] | None,
|
||||||
) -> RunResultBase | None:
|
) -> RunResultBase | None:
|
||||||
"""Non-chat mode keeps running until finish_scan / agent_finish settles status."""
|
"""Drive an agent until an explicit lifecycle tool settles its status.
|
||||||
|
|
||||||
|
A turn that ends without ``finish_scan``, ``agent_finish``,
|
||||||
|
``respond_to_user``, or ``wait_for_agents`` leaves the agent ``running``:
|
||||||
|
plain text never terminates a run and never yields to the user. Such a turn
|
||||||
|
is nudged back into a tool call, bounded by a recovery limit.
|
||||||
|
"""
|
||||||
result: RunResultBase | None = None
|
result: RunResultBase | None = None
|
||||||
input_data: Any = initial_input
|
input_data: Any = initial_input
|
||||||
invalid_final_outputs = 0
|
recovery_limit = _INTERACTIVE_TOOL_RECOVERY_LIMIT if interactive else max(1, max_turns)
|
||||||
invalid_final_output_limit = max(1, max_turns)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if coordinator.budget_stopped:
|
if coordinator.budget_stopped:
|
||||||
@@ -455,69 +468,125 @@ async def _run_noninteractive_until_lifecycle(
|
|||||||
await coordinator.set_status(agent_id, "stopped")
|
await coordinator.set_status(agent_id, "stopped")
|
||||||
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
raise SubagentBudgetReservedError("scan reached the sub-agent budget reserve")
|
||||||
|
|
||||||
result = await _run_cycle(
|
if interactive:
|
||||||
agent,
|
result = await _run_cycle_parked(
|
||||||
coordinator,
|
agent,
|
||||||
agent_id,
|
coordinator,
|
||||||
input_data=input_data,
|
agent_id,
|
||||||
run_config=run_config,
|
input_data=input_data,
|
||||||
context=context,
|
run_config=run_config,
|
||||||
max_turns=max_turns,
|
context=context,
|
||||||
session=session,
|
max_turns=max_turns,
|
||||||
interactive=False,
|
session=session,
|
||||||
event_sink=event_sink,
|
event_sink=event_sink,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
result = await _run_cycle(
|
||||||
|
agent,
|
||||||
|
coordinator,
|
||||||
|
agent_id,
|
||||||
|
input_data=input_data,
|
||||||
|
run_config=run_config,
|
||||||
|
context=context,
|
||||||
|
max_turns=max_turns,
|
||||||
|
session=session,
|
||||||
|
interactive=False,
|
||||||
|
event_sink=event_sink,
|
||||||
|
hooks=hooks,
|
||||||
|
)
|
||||||
|
|
||||||
status = await _agent_status(coordinator, agent_id)
|
status = await _agent_status(coordinator, agent_id)
|
||||||
if status != "running":
|
if status != "running":
|
||||||
|
await coordinator.reset_recovery(agent_id)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
invalid_final_outputs += 1
|
recoveries = await coordinator.record_recovery(agent_id)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"agent %s produced non-lifecycle final output in non-interactive mode; "
|
"agent %s ended a turn without a lifecycle tool call (interactive=%s); "
|
||||||
"forcing tool continuation (%d/%d): %s",
|
"forcing tool continuation (%d/%d): %s",
|
||||||
agent_id,
|
agent_id,
|
||||||
invalid_final_outputs,
|
interactive,
|
||||||
invalid_final_output_limit,
|
recoveries,
|
||||||
|
recovery_limit,
|
||||||
_final_output_preview(result),
|
_final_output_preview(result),
|
||||||
)
|
)
|
||||||
|
|
||||||
if invalid_final_outputs >= invalid_final_output_limit:
|
if recoveries >= recovery_limit:
|
||||||
await coordinator.set_status(agent_id, "crashed")
|
return await _exhausted_recovery(coordinator, agent_id, result, interactive=interactive)
|
||||||
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
|
|
||||||
raise MaxTurnsExceeded(
|
|
||||||
"Agent exhausted non-interactive recovery attempts without calling "
|
|
||||||
"finish_scan or agent_finish."
|
|
||||||
)
|
|
||||||
|
|
||||||
input_data = await _append_noninteractive_tool_required_message(
|
input_data = await _append_tool_required_message(
|
||||||
session=session,
|
session=session,
|
||||||
context=context,
|
context=context,
|
||||||
attempt=invalid_final_outputs,
|
attempt=recoveries,
|
||||||
limit=invalid_final_output_limit,
|
limit=recovery_limit,
|
||||||
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_WAITING_AUTO_RESUME_TIMEOUT_S = 600.0
|
async def _exhausted_recovery(
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
|
agent_id: str,
|
||||||
|
result: RunResultBase | None,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
) -> RunResultBase | None:
|
||||||
|
"""Settle an agent that never recovered into a tool call.
|
||||||
|
|
||||||
|
Interactive runs park instead of dying: a human is attached and can message
|
||||||
|
any agent, so the scan stays resumable. Autonomous runs have nobody to
|
||||||
|
resume them, so they fail loudly.
|
||||||
|
"""
|
||||||
|
if not interactive:
|
||||||
|
await coordinator.set_status(agent_id, "crashed")
|
||||||
|
await _notify_parent_on_terminal(coordinator, agent_id, "crashed")
|
||||||
|
raise MaxTurnsExceeded(
|
||||||
|
"Agent exhausted recovery attempts without calling finish_scan or agent_finish."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"agent %s exhausted tool-call recovery attempts; parking until a message arrives",
|
||||||
|
agent_id,
|
||||||
|
)
|
||||||
|
await coordinator.park_waiting(agent_id, wait_kind="stalled")
|
||||||
|
# A parked child owes its parent a completion report it can no longer send. The
|
||||||
|
# parent is an agent, not a watching human, so nothing else tells it to stop
|
||||||
|
# waiting and it burns its full timeout on a message that is never coming.
|
||||||
|
await _notify_parent_on_stall(coordinator, agent_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_WAITING_AUTO_RESUME_TIMEOUT_S = 300.0
|
||||||
|
|
||||||
|
# An agent that parks again after every auto-resume makes no progress, so stop
|
||||||
|
# spending a model turn per timeout and leave it parked for a real message.
|
||||||
|
_MAX_IDLE_AUTO_RESUMES = 3
|
||||||
|
|
||||||
|
|
||||||
async def _plain_waiting_timeout(
|
async def _plain_waiting_timeout(
|
||||||
coordinator: AgentCoordinator,
|
coordinator: AgentCoordinator,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
context: dict[str, Any],
|
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
"""Auto-resume timeout for a plainly-waiting subagent; None waits forever."""
|
"""Auto-resume timeout for a parked agent; None waits until a message arrives.
|
||||||
if context.get("parent_id") is None:
|
|
||||||
return None
|
Driven by what the agent is waiting on, not by where it sits in the graph:
|
||||||
|
the user can message any agent, so an agent awaiting a human parks
|
||||||
|
indefinitely whether or not it is the root. Only an agent awaiting other
|
||||||
|
agents is re-checked on a timer, and only until it has spent its idle
|
||||||
|
budget re-parking without hearing anything.
|
||||||
|
"""
|
||||||
async with coordinator._lock:
|
async with coordinator._lock:
|
||||||
status = coordinator.statuses.get(agent_id)
|
status = coordinator.statuses.get(agent_id)
|
||||||
has_error = agent_id in coordinator.errors
|
has_error = agent_id in coordinator.errors
|
||||||
runtime = coordinator.runtimes.get(agent_id)
|
runtime = coordinator.runtimes.get(agent_id)
|
||||||
gated = runtime.user_wake_required if runtime is not None else False
|
gated = runtime.user_wake_required if runtime is not None else False
|
||||||
if status == "waiting" and not has_error and not gated:
|
wait_kind = coordinator.wait_kinds.get(agent_id)
|
||||||
return _WAITING_AUTO_RESUME_TIMEOUT_S
|
idle_resumes = coordinator.idle_resume_counts.get(agent_id, 0)
|
||||||
return None
|
if status != "waiting" or has_error or gated:
|
||||||
|
return None
|
||||||
|
if wait_kind != "agents" or idle_resumes >= _MAX_IDLE_AUTO_RESUMES:
|
||||||
|
return None
|
||||||
|
return _WAITING_AUTO_RESUME_TIMEOUT_S
|
||||||
|
|
||||||
|
|
||||||
async def _run_cycle_parked(
|
async def _run_cycle_parked(
|
||||||
@@ -724,27 +793,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
|
|||||||
await _notify_parent_on_terminal(coordinator, agent_id, status)
|
await _notify_parent_on_terminal(coordinator, agent_id, status)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
await _settle_run_result(coordinator, agent_id, interactive)
|
|
||||||
return cast("RunResultBase | None", stream)
|
return cast("RunResultBase | None", stream)
|
||||||
|
|
||||||
|
|
||||||
async def _settle_run_result(
|
|
||||||
coordinator: AgentCoordinator,
|
|
||||||
agent_id: str,
|
|
||||||
interactive: bool,
|
|
||||||
) -> None:
|
|
||||||
async with coordinator._lock:
|
|
||||||
current_status = coordinator.statuses.get(agent_id)
|
|
||||||
|
|
||||||
if current_status != "running":
|
|
||||||
return
|
|
||||||
|
|
||||||
if not interactive:
|
|
||||||
return
|
|
||||||
|
|
||||||
await coordinator.set_status(agent_id, "waiting")
|
|
||||||
|
|
||||||
|
|
||||||
async def _agent_status(coordinator: AgentCoordinator, agent_id: str) -> Status | None:
|
async def _agent_status(coordinator: AgentCoordinator, agent_id: str) -> Status | None:
|
||||||
async with coordinator._lock:
|
async with coordinator._lock:
|
||||||
return coordinator.statuses.get(agent_id)
|
return coordinator.statuses.get(agent_id)
|
||||||
@@ -760,23 +811,37 @@ def _final_output_preview(result: RunResultBase | None) -> str:
|
|||||||
return text[:300]
|
return text[:300]
|
||||||
|
|
||||||
|
|
||||||
async def _append_noninteractive_tool_required_message(
|
async def _append_tool_required_message(
|
||||||
*,
|
*,
|
||||||
session: Session | None,
|
session: Session | None,
|
||||||
context: dict[str, Any],
|
context: dict[str, Any],
|
||||||
attempt: int,
|
attempt: int,
|
||||||
limit: int,
|
limit: int,
|
||||||
|
interactive: bool,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
|
finish_tool = "finish_scan" if context.get("parent_id") is None else "agent_finish"
|
||||||
message = (
|
if interactive:
|
||||||
"Your previous response ended the autonomous Strix run without a lifecycle tool call. "
|
message = (
|
||||||
"That is invalid in non-interactive mode; plain text final answers are ignored. "
|
"Your previous message ended a turn without a tool call. Plain text never ends "
|
||||||
"Continue immediately and call exactly one tool. "
|
"execution and never hands control to the user: it is shown to the user, and the "
|
||||||
f"If your work is complete, call {finish_tool}. "
|
"run continues. Continue immediately and call exactly one tool. "
|
||||||
"If you are blocked waiting for another agent, call wait_for_message. "
|
"If you have something to tell the user and nothing to do until they reply, "
|
||||||
"Otherwise use the appropriate execution or planning tool. "
|
"call respond_to_user. "
|
||||||
f"This is recovery attempt {attempt}/{limit}."
|
"If you are blocked waiting for another agent, call wait_for_agents. "
|
||||||
)
|
f"If the whole engagement is complete, call {finish_tool}. "
|
||||||
|
"Otherwise use the appropriate execution or planning tool. "
|
||||||
|
f"This is recovery attempt {attempt}/{limit}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"Your previous response ended the autonomous Strix 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. "
|
||||||
|
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. "
|
||||||
|
f"This is recovery attempt {attempt}/{limit}."
|
||||||
|
)
|
||||||
item = {"role": "user", "content": message}
|
item = {"role": "user", "content": message}
|
||||||
if session is None:
|
if session is None:
|
||||||
return [item]
|
return [item]
|
||||||
@@ -803,6 +868,36 @@ _TERMINAL_NOTICE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_STALL_NOTICE = (
|
||||||
|
"[Agent stalled] {name} ({agent_id}) kept ending turns without a tool call and is "
|
||||||
|
"parked until it receives a message. It will not send a completion report on its "
|
||||||
|
"own: either message it with a concrete next step to unblock it, or stop waiting on "
|
||||||
|
"it and account for its unfinished subtask."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_parent_on_stall(
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
|
agent_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""Tell the parent that a child parked mid-task, so it stops waiting blindly."""
|
||||||
|
async with coordinator._lock:
|
||||||
|
parent = coordinator.parent_of.get(agent_id)
|
||||||
|
name = coordinator.names.get(agent_id, agent_id)
|
||||||
|
if parent is None:
|
||||||
|
return
|
||||||
|
await coordinator.send(
|
||||||
|
parent,
|
||||||
|
{
|
||||||
|
"from": agent_id,
|
||||||
|
"type": "stalled",
|
||||||
|
"priority": "high",
|
||||||
|
"content": _STALL_NOTICE.format(name=name, agent_id=agent_id),
|
||||||
|
},
|
||||||
|
interrupt=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _notify_parent_on_terminal(
|
async def _notify_parent_on_terminal(
|
||||||
coordinator: AgentCoordinator,
|
coordinator: AgentCoordinator,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from . import (
|
|||||||
notes_renderer,
|
notes_renderer,
|
||||||
proxy_renderer,
|
proxy_renderer,
|
||||||
reporting_renderer,
|
reporting_renderer,
|
||||||
|
respond_renderer,
|
||||||
shell_renderer,
|
shell_renderer,
|
||||||
thinking_renderer,
|
thinking_renderer,
|
||||||
todo_renderer,
|
todo_renderer,
|
||||||
@@ -23,6 +24,7 @@ __all__ = [
|
|||||||
"proxy_renderer",
|
"proxy_renderer",
|
||||||
"render_tool_widget",
|
"render_tool_widget",
|
||||||
"reporting_renderer",
|
"reporting_renderer",
|
||||||
|
"respond_renderer",
|
||||||
"shell_renderer",
|
"shell_renderer",
|
||||||
"thinking_renderer",
|
"thinking_renderer",
|
||||||
"todo_renderer",
|
"todo_renderer",
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ class AgentFinishRenderer(BaseToolRenderer):
|
|||||||
|
|
||||||
|
|
||||||
@register_tool_renderer
|
@register_tool_renderer
|
||||||
class WaitForMessageRenderer(BaseToolRenderer):
|
class WaitForAgentsRenderer(BaseToolRenderer):
|
||||||
tool_name: ClassVar[str] = "wait_for_message"
|
tool_name: ClassVar[str] = "wait_for_agents"
|
||||||
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
|
css_classes: ClassVar[list[str]] = ["tool-call", "agents-graph-tool"]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
from .agent_message_renderer import AgentMessageRenderer
|
||||||
|
from .base_renderer import BaseToolRenderer
|
||||||
|
from .registry import register_tool_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@register_tool_renderer
|
||||||
|
class RespondToUserRenderer(BaseToolRenderer):
|
||||||
|
"""Render a reply as the agent's own prose, not as a tool call.
|
||||||
|
|
||||||
|
``respond_to_user`` carries the message the user is meant to read, so it
|
||||||
|
gets the same markdown treatment as a plain assistant turn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tool_name: ClassVar[str] = "respond_to_user"
|
||||||
|
css_classes: ClassVar[list[str]] = ["tool-call", "respond-tool"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls, tool_data: dict[str, Any]) -> Static:
|
||||||
|
args = tool_data.get("args", {})
|
||||||
|
message = args.get("message", "")
|
||||||
|
|
||||||
|
text = Text()
|
||||||
|
if message:
|
||||||
|
text.append_text(AgentMessageRenderer.render_simple(message))
|
||||||
|
text.append("\n\n")
|
||||||
|
text.append("○ ", style="#6b7280")
|
||||||
|
text.append("waiting for your reply", style="dim")
|
||||||
|
|
||||||
|
css_classes = cls.get_css_classes(tool_data.get("status", "unknown"))
|
||||||
|
return Static(text, classes=css_classes)
|
||||||
+1
-1
@@ -54,7 +54,7 @@ export default function AgentCommsRenderer({ toolName, args }: ToolRendererProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolName === "wait_for_message") {
|
if (toolName === "wait_for_agents") {
|
||||||
const reason = (args.reason as string) ?? "";
|
const reason = (args.reason as string) ?? "";
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ToolRendererProps } from "@/types/events";
|
||||||
|
import Markdown from "./Markdown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `respond_to_user` carries the message the user is meant to read, so it renders
|
||||||
|
* as the agent's own prose rather than as a tool call.
|
||||||
|
*/
|
||||||
|
export default function RespondRenderer({ args }: ToolRendererProps) {
|
||||||
|
const message = (args.message as string) ?? "";
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Markdown text={message} />
|
||||||
|
<div className="mt-1.5 text-[#888] text-[13px]">waiting for your reply</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import NotesRenderer from "./NotesRenderer";
|
|||||||
import TodoRenderer from "./TodoRenderer";
|
import TodoRenderer from "./TodoRenderer";
|
||||||
import FallbackRenderer from "./FallbackRenderer";
|
import FallbackRenderer from "./FallbackRenderer";
|
||||||
import LoadSkillRenderer from "./LoadSkillRenderer";
|
import LoadSkillRenderer from "./LoadSkillRenderer";
|
||||||
|
import RespondRenderer from "./RespondRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
* Tool-renderer mapping — data-driven, keyed by the engine's tool *family*.
|
||||||
@@ -104,10 +105,10 @@ const CATEGORY_TOOLS: Record<ToolCategory, readonly string[]> = {
|
|||||||
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
proxy: ["list_requests", "view_request", "repeat_request", "list_sitemap", "view_sitemap_entry", "scope_rules", "send_request"],
|
||||||
reporting: ["create_vulnerability_report", "list_reports", "get_report"],
|
reporting: ["create_vulnerability_report", "list_reports", "get_report"],
|
||||||
thinking: ["think"],
|
thinking: ["think"],
|
||||||
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_message", "view_agent_graph", "stop_agent"],
|
agents: ["create_agent", "agent_finish", "send_message_to_agent", "wait_for_agents", "view_agent_graph", "stop_agent"],
|
||||||
search: ["web_search"],
|
search: ["web_search"],
|
||||||
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
|
// scan_start_info / subagent_start_info are strix-app synthetic events; finish_scan is the engine's
|
||||||
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan"],
|
lifecycle: ["scan_start_info", "subagent_start_info", "finish_scan", "respond_to_user"],
|
||||||
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
|
notes: ["create_note", "delete_note", "update_note", "list_notes", "get_note"],
|
||||||
skills: ["load_skill"],
|
skills: ["load_skill"],
|
||||||
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
todos: ["create_todo", "list_todos", "update_todo", "mark_todo_done", "mark_todo_pending", "delete_todo"],
|
||||||
@@ -127,6 +128,7 @@ const TOOL_CATEGORY: Record<string, ToolCategory> = Object.fromEntries(
|
|||||||
*/
|
*/
|
||||||
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
|
const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps>>> = {
|
||||||
finish_scan: FinishRenderer,
|
finish_scan: FinishRenderer,
|
||||||
|
respond_to_user: RespondRenderer,
|
||||||
apply_patch: ApplyPatchRenderer,
|
apply_patch: ApplyPatchRenderer,
|
||||||
view_image: ViewImageRenderer,
|
view_image: ViewImageRenderer,
|
||||||
list_reports: ReportListRenderer,
|
list_reports: ReportListRenderer,
|
||||||
@@ -140,7 +142,8 @@ const RENDERER_OVERRIDES: Partial<Record<string, ComponentType<ToolRendererProps
|
|||||||
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
|
const ICON_OVERRIDES: Partial<Record<string, ToolIconMeta>> = {
|
||||||
agent_finish: { icon: Flag, color: "text-cyan-400" },
|
agent_finish: { icon: Flag, color: "text-cyan-400" },
|
||||||
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
|
send_message_to_agent: { icon: MessageCircle, color: "text-cyan-400" },
|
||||||
wait_for_message: { icon: MessageCircle, color: "text-cyan-400" },
|
wait_for_agents: { icon: MessageCircle, color: "text-cyan-400" },
|
||||||
|
respond_to_user: { icon: MessageCircle, color: "text-emerald-400" },
|
||||||
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
|
view_agent_graph: { icon: Eye, color: "text-cyan-400" },
|
||||||
stop_agent: { icon: Ban, color: "text-red-400" },
|
stop_agent: { icon: Ban, color: "text-red-400" },
|
||||||
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
|
scan_start_info: { icon: Crosshair, color: "text-emerald-400" },
|
||||||
|
|||||||
+50
-50
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<title>Strix Results</title>
|
<title>Strix Results</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DzvI_0HX.js"></script>
|
<script type="module" crossorigin src="./assets/index-CGvQq6oe.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-C3kQ5kk8.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-C3kQ5kk8.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -218,25 +218,43 @@ def _session_items_payload(items: list[Any]) -> list[dict[str, Any]]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@function_tool(timeout=601)
|
_WAIT_DEFAULT_TIMEOUT_S = 300
|
||||||
async def wait_for_message( # noqa: PLR0911
|
# Enforced by the SDK around the whole tool call, so it caps an oversized
|
||||||
|
# ``timeout_seconds`` the model asks for. One second of headroom lets the
|
||||||
|
# tool's own timeout fire first and return a clean result.
|
||||||
|
_WAIT_HARD_CEILING_S = _WAIT_DEFAULT_TIMEOUT_S + 1
|
||||||
|
|
||||||
|
|
||||||
|
@function_tool(timeout=_WAIT_HARD_CEILING_S)
|
||||||
|
async def wait_for_agents( # noqa: PLR0911
|
||||||
ctx: RunContextWrapper,
|
ctx: RunContextWrapper,
|
||||||
reason: str = "Waiting for messages from other agents",
|
reason: str = "Waiting for messages from other agents",
|
||||||
timeout_seconds: int = 600,
|
timeout_seconds: int = _WAIT_DEFAULT_TIMEOUT_S,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Pause this agent until a message lands in its inbox (or timeout).
|
"""Pause until another AGENT messages you (or the timeout elapses).
|
||||||
|
|
||||||
Use when you have nothing useful to do until a child/peer responds
|
Use when you have nothing useful to do until a child or peer
|
||||||
— typically after spawning subagents and you want to wait for
|
responds — typically after spawning subagents and you want their
|
||||||
their completion reports. The agent automatically resumes when any
|
completion reports. You resume the instant any message arrives, so
|
||||||
message arrives, so pick a ``timeout_seconds`` proportional to the
|
size ``timeout_seconds`` to the work you're awaiting.
|
||||||
work you're awaiting.
|
|
||||||
|
**This tool is only for waiting on other agents.** Two things it is
|
||||||
|
NOT for:
|
||||||
|
|
||||||
|
- **Talking to the user.** Use ``respond_to_user``, which delivers
|
||||||
|
your message and hands control back in one call.
|
||||||
|
- **Waiting for a long-running command.** This tool does not watch
|
||||||
|
processes at all — it sleeps until a *message* arrives, so it
|
||||||
|
burns the full timeout even if your command finished a second
|
||||||
|
later. Poll the process instead: ``exec_command`` returns a
|
||||||
|
session/process id, and ``write_stdin`` with ``chars=""`` returns
|
||||||
|
as soon as there is new output or the process exits.
|
||||||
|
|
||||||
**Critical caveats:**
|
**Critical caveats:**
|
||||||
|
|
||||||
- **Never** call this if you finished your own task and have **no**
|
- **Never** call this if you have no agents left to hear from —
|
||||||
child agents running — that's a permanent stall. Call
|
that just strands you until the timeout. Call ``finish_scan``
|
||||||
``finish_scan`` (root) or ``agent_finish`` (subagent) instead.
|
(root) or ``agent_finish`` (subagent) instead.
|
||||||
- If you're waiting on an agent that **isn't your child**, message
|
- If you're waiting on an agent that **isn't your child**, message
|
||||||
it first asking it to ping you when done — otherwise it has no
|
it first asking it to ping you when done — otherwise it has no
|
||||||
reason to send to your inbox and you'll wait the full timeout.
|
reason to send to your inbox and you'll wait the full timeout.
|
||||||
@@ -247,7 +265,8 @@ async def wait_for_message( # noqa: PLR0911
|
|||||||
reason: One-line note shown in graph snapshots while you're
|
reason: One-line note shown in graph snapshots while you're
|
||||||
waiting (helps a human or sibling agent debug who's stuck
|
waiting (helps a human or sibling agent debug who's stuck
|
||||||
on what).
|
on what).
|
||||||
timeout_seconds: Max seconds to wait (default 600). This is only
|
timeout_seconds: Max seconds to wait (default 300, and values above
|
||||||
|
that are cut short by a hard ceiling). This is only
|
||||||
a cap — the tool returns the INSTANT a message arrives, so a
|
a cap — the tool returns the INSTANT a message arrives, so a
|
||||||
larger value never makes you wait longer when the reply does
|
larger value never makes you wait longer when the reply does
|
||||||
come. Right-size it to what you're waiting on: a short wait
|
come. Right-size it to what you're waiting on: a short wait
|
||||||
@@ -257,9 +276,7 @@ async def wait_for_message( # noqa: PLR0911
|
|||||||
bites when the expected message never arrives — so an oversized
|
bites when the expected message never arrives — so an oversized
|
||||||
timeout on a trivial wait just strands you idle until it
|
timeout on a trivial wait just strands you idle until it
|
||||||
elapses. On timeout the tool returns and you decide whether to
|
elapses. On timeout the tool returns and you decide whether to
|
||||||
keep working or wait again. (Applies to autonomous multi-agent
|
keep working or wait again.
|
||||||
runs; in interactive/chat sessions the agent instead parks until
|
|
||||||
a message arrives and this cap is not enforced.)
|
|
||||||
"""
|
"""
|
||||||
inner = _ctx(ctx)
|
inner = _ctx(ctx)
|
||||||
coordinator = coordinator_from_context(inner)
|
coordinator = coordinator_from_context(inner)
|
||||||
@@ -302,7 +319,7 @@ async def wait_for_message( # noqa: PLR0911
|
|||||||
)
|
)
|
||||||
|
|
||||||
if interactive:
|
if interactive:
|
||||||
await coordinator.park_waiting(me)
|
await coordinator.park_waiting(me, wait_kind="agents")
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -314,7 +331,7 @@ async def wait_for_message( # noqa: PLR0911
|
|||||||
default=str,
|
default=str,
|
||||||
)
|
)
|
||||||
|
|
||||||
await coordinator.park_waiting(me)
|
await coordinator.park_waiting(me, wait_kind="agents")
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
|
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
@@ -373,7 +390,7 @@ async def create_agent(
|
|||||||
|
|
||||||
Decompose complex pentests by handing focused subtasks to dedicated
|
Decompose complex pentests by handing focused subtasks to dedicated
|
||||||
children. The child runs asynchronously — the parent continues
|
children. The child runs asynchronously — the parent continues
|
||||||
immediately and can ``wait_for_message`` later (or just keep
|
immediately and can ``wait_for_agents`` later (or just keep
|
||||||
working in parallel). When the child calls ``agent_finish``, its
|
working in parallel). When the child calls ``agent_finish``, its
|
||||||
completion report lands in the parent's inbox.
|
completion report lands in the parent's inbox.
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ async def finish_scan(
|
|||||||
execution stops. There is no draft mode and no second chance: never
|
execution stops. There is no draft mode and no second chance: never
|
||||||
submit placeholder, provisional, or "checking if done" text in any
|
submit placeholder, provisional, or "checking if done" text in any
|
||||||
field, and never call ``finish_scan`` to poll whether subagents are
|
field, and never call ``finish_scan`` to poll whether subagents are
|
||||||
done (use ``view_agent_graph`` / ``wait_for_message`` for that).
|
done (use ``view_agent_graph`` / ``wait_for_agents`` for that).
|
||||||
Call it exactly ONCE, only when every field holds genuine, finished
|
Call it exactly ONCE, only when every field holds genuine, finished
|
||||||
assessment prose.
|
assessment prose.
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ async def finish_scan(
|
|||||||
summary. If ANY agent is in ``running`` / ``waiting`` state,
|
summary. If ANY agent is in ``running`` / ``waiting`` state,
|
||||||
you MUST NOT call ``finish_scan`` yet —
|
you MUST NOT call ``finish_scan`` yet —
|
||||||
wrap them up first via ``send_message_to_agent`` (ask them to
|
wrap them up first via ``send_message_to_agent`` (ask them to
|
||||||
finish), ``wait_for_message`` (block until their report
|
finish), ``wait_for_agents`` (block until their report
|
||||||
arrives), or ``stop_agent`` (graceful cancel). Only ``completed``
|
arrives), or ``stop_agent`` (graceful cancel). Only ``completed``
|
||||||
/ ``crashed`` / ``stopped`` agents are safe to leave behind.
|
/ ``crashed`` / ``stopped`` agents are safe to leave behind.
|
||||||
Calling ``finish_scan`` while children are alive orphans their
|
Calling ``finish_scan`` while children are alive orphans their
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""User-facing reply tool for interactive sessions."""
|
||||||
|
|
||||||
|
from strix.tools.respond.tool import respond_to_user
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["respond_to_user"]
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""``respond_to_user`` — deliver a reply and hand control back to the user."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agents import RunContextWrapper, function_tool
|
||||||
|
|
||||||
|
from strix.core.agents import coordinator_from_context
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
|
||||||
|
return ctx.context if isinstance(ctx.context, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
@function_tool
|
||||||
|
async def respond_to_user(ctx: RunContextWrapper, message: str) -> str:
|
||||||
|
"""Answer the user and hand control back to them.
|
||||||
|
|
||||||
|
This is the ONLY way to yield to the user. Delivering the message and
|
||||||
|
yielding are the same call on purpose: there is no way to answer and
|
||||||
|
then forget to stop, and no way to stop without having answered.
|
||||||
|
|
||||||
|
Call it when you have something for the user and nothing to do until
|
||||||
|
they reply — you answered their question, you need a decision or a
|
||||||
|
credential only they can give, or you finished a chunk of work and
|
||||||
|
want direction. You resume exactly where you left off when they
|
||||||
|
reply, with everything you have done so far intact.
|
||||||
|
|
||||||
|
Do NOT call it to narrate progress or to think out loud. Plain text
|
||||||
|
is still shown to the user as you work, so say whatever you like
|
||||||
|
mid-task without stopping; ``respond_to_user`` is specifically the
|
||||||
|
act of *waiting* for them. Every call costs the user their attention.
|
||||||
|
|
||||||
|
Not for these:
|
||||||
|
|
||||||
|
- **Waiting on another agent** (a child's report, a peer's reply) —
|
||||||
|
use ``wait_for_agents``.
|
||||||
|
- **Ending the engagement** — use ``finish_scan`` (root) or
|
||||||
|
``agent_finish`` (subagent). Those are terminal; this is a pause.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: What to say to the user. Self-contained: they may not
|
||||||
|
have followed the tool calls that led here. Lead with the
|
||||||
|
answer or the decision you need, and if you are blocked, say
|
||||||
|
exactly what you need from them.
|
||||||
|
"""
|
||||||
|
inner = _ctx(ctx)
|
||||||
|
coordinator = coordinator_from_context(inner)
|
||||||
|
me = inner.get("agent_id")
|
||||||
|
interactive = bool(inner.get("interactive", False))
|
||||||
|
|
||||||
|
if coordinator is None or me is None:
|
||||||
|
return json.dumps(
|
||||||
|
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not interactive:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
"No user is attached to an autonomous run. Keep working, and call "
|
||||||
|
"finish_scan (root) or agent_finish (subagent) when the task is done."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with coordinator._lock:
|
||||||
|
stopped = coordinator.statuses.get(me) == "stopped"
|
||||||
|
if stopped:
|
||||||
|
return json.dumps(
|
||||||
|
{"success": True, "wait_outcome": "stopped", "message": message},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A message that arrived while this turn was running is the user already
|
||||||
|
# talking: take it now instead of parking for one they have sent.
|
||||||
|
pending, _ = await coordinator.consume_pending(me)
|
||||||
|
if pending > 0:
|
||||||
|
await coordinator.mark_running(me)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"wait_outcome": "message_arrived",
|
||||||
|
"pending_messages": pending,
|
||||||
|
"message": message,
|
||||||
|
"note": "Your reply was delivered; the user had already sent a new message.",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
await coordinator.park_waiting(me, wait_kind="user")
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"wait_outcome": "waiting",
|
||||||
|
"message": message,
|
||||||
|
"note": "Reply delivered; parked until the user responds.",
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
@@ -2,18 +2,29 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from agents.tool import FunctionTool
|
from agents.tool import FunctionTool
|
||||||
|
|
||||||
from strix.agents import factory
|
from strix.agents import factory
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from agents.tool_context import ToolContext
|
||||||
|
|
||||||
|
|
||||||
def _tool(name: str) -> FunctionTool:
|
def _tool(name: str) -> FunctionTool:
|
||||||
|
# A per-tool closure keeps two same-named tools unequal, which is what the
|
||||||
|
# duplicate-name tests exercise.
|
||||||
|
async def invoke(_ctx: ToolContext[Any], _input: str) -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
return FunctionTool(
|
return FunctionTool(
|
||||||
name=name,
|
name=name,
|
||||||
description="test tool",
|
description="test tool",
|
||||||
params_json_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
params_json_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
||||||
on_invoke_tool=lambda _ctx, _inp: "ok",
|
on_invoke_tool=invoke,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,3 +97,18 @@ def test_no_override_renders_builtin_prompt() -> None:
|
|||||||
|
|
||||||
assert isinstance(agent.instructions, str)
|
assert isinstance(agent.instructions, str)
|
||||||
assert agent.instructions != ""
|
assert agent.instructions != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_respond_to_user_is_interactive_only() -> None:
|
||||||
|
"""Yielding to the user is meaningless when no user is attached."""
|
||||||
|
interactive = factory.build_strix_agent(is_root=True, interactive=True)
|
||||||
|
autonomous = factory.build_strix_agent(is_root=True, interactive=False)
|
||||||
|
|
||||||
|
assert "respond_to_user" in [t.name for t in interactive.tools]
|
||||||
|
assert "respond_to_user" not in [t.name for t in autonomous.tools]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wait_for_agents_is_available_in_both_modes() -> None:
|
||||||
|
for interactive in (True, False):
|
||||||
|
agent = factory.build_strix_agent(is_root=True, interactive=interactive)
|
||||||
|
assert "wait_for_agents" in [t.name for t in agent.tools]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from strix.core import execution
|
from strix.core import execution
|
||||||
from strix.core.agents import AgentCoordinator
|
from strix.core.agents import AgentCoordinator, WaitKind
|
||||||
from strix.core.execution import _start_child_runner, run_agent_loop
|
from strix.core.execution import _start_child_runner, run_agent_loop
|
||||||
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
from strix.core.hooks import BudgetExceededError, ReportUsageHooks
|
||||||
from strix.core.sessions import open_agent_session
|
from strix.core.sessions import open_agent_session
|
||||||
@@ -42,23 +42,32 @@ class _FakeStream:
|
|||||||
hooks: ReportUsageHooks,
|
hooks: ReportUsageHooks,
|
||||||
context: dict[str, Any],
|
context: dict[str, Any],
|
||||||
agent: Any,
|
agent: Any,
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._ledger = ledger
|
self._ledger = ledger
|
||||||
self._hooks = hooks
|
self._hooks = hooks
|
||||||
self._context = context
|
self._context = context
|
||||||
self._agent = agent
|
self._agent = agent
|
||||||
|
self._coordinator = coordinator
|
||||||
self.run_loop_exception: BaseException | None = None
|
self.run_loop_exception: BaseException | None = None
|
||||||
self.final_output = None
|
self.final_output = None
|
||||||
|
|
||||||
async def stream_events(self) -> AsyncIterator[Any]:
|
async def stream_events(self) -> AsyncIterator[Any]:
|
||||||
|
agent_id = str(self._context.get("agent_id"))
|
||||||
self._ledger.cost += COST_PER_CALL
|
self._ledger.cost += COST_PER_CALL
|
||||||
self._ledger.calls.append(str(self._context.get("agent_id")))
|
self._ledger.calls.append(agent_id)
|
||||||
ctx_wrapper = MagicMock()
|
ctx_wrapper = MagicMock()
|
||||||
ctx_wrapper.context = self._context
|
ctx_wrapper.context = self._context
|
||||||
try:
|
try:
|
||||||
await self._hooks.on_llm_end(ctx_wrapper, self._agent, MagicMock())
|
await self._hooks.on_llm_end(ctx_wrapper, self._agent, MagicMock())
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.run_loop_exception = exc
|
self.run_loop_exception = exc
|
||||||
|
# Stand in for the explicit yield tool a real turn ends with. Without it
|
||||||
|
# every turn looks like a forgotten tool call and burns the recovery
|
||||||
|
# budget, which is a different scenario from the one under test here.
|
||||||
|
if self._coordinator.statuses.get(agent_id) == "running":
|
||||||
|
wait_kind: WaitKind = "user" if self._context.get("parent_id") is None else "agents"
|
||||||
|
await self._coordinator.park_waiting(agent_id, wait_kind=wait_kind)
|
||||||
items: tuple[Any, ...] = ()
|
items: tuple[Any, ...] = ()
|
||||||
for item in items:
|
for item in items:
|
||||||
yield item
|
yield item
|
||||||
@@ -67,7 +76,7 @@ class _FakeStream:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def _fake_runner(ledger: _FakeLedger) -> Any:
|
def _fake_runner(ledger: _FakeLedger, coordinator: AgentCoordinator) -> Any:
|
||||||
class _FakeRunner:
|
class _FakeRunner:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def run_streamed(
|
def run_streamed(
|
||||||
@@ -80,7 +89,13 @@ def _fake_runner(ledger: _FakeLedger) -> Any:
|
|||||||
session: Any, # noqa: ARG004
|
session: Any, # noqa: ARG004
|
||||||
hooks: ReportUsageHooks,
|
hooks: ReportUsageHooks,
|
||||||
) -> _FakeStream:
|
) -> _FakeStream:
|
||||||
return _FakeStream(ledger=ledger, hooks=hooks, context=context, agent=agent)
|
return _FakeStream(
|
||||||
|
ledger=ledger,
|
||||||
|
hooks=hooks,
|
||||||
|
context=context,
|
||||||
|
agent=agent,
|
||||||
|
coordinator=coordinator,
|
||||||
|
)
|
||||||
|
|
||||||
return _FakeRunner
|
return _FakeRunner
|
||||||
|
|
||||||
@@ -103,10 +118,10 @@ async def test_full_budget_lifecycle_reserve_then_cap( # noqa: PLR0915
|
|||||||
) -> None:
|
) -> None:
|
||||||
ledger = _FakeLedger()
|
ledger = _FakeLedger()
|
||||||
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET)
|
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET)
|
||||||
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger))
|
coordinator = AgentCoordinator()
|
||||||
|
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger, coordinator))
|
||||||
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
||||||
|
|
||||||
coordinator = AgentCoordinator()
|
|
||||||
db_path = tmp_path / "agents.sqlite"
|
db_path = tmp_path / "agents.sqlite"
|
||||||
sessions: list[Any] = []
|
sessions: list[Any] = []
|
||||||
run_config = MagicMock()
|
run_config = MagicMock()
|
||||||
@@ -218,7 +233,6 @@ async def test_respawned_children_after_reserve_never_spend(
|
|||||||
ledger = _FakeLedger()
|
ledger = _FakeLedger()
|
||||||
ledger.cost = 9.5
|
ledger.cost = 9.5
|
||||||
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET)
|
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET)
|
||||||
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger))
|
|
||||||
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
||||||
|
|
||||||
coordinator = AgentCoordinator()
|
coordinator = AgentCoordinator()
|
||||||
@@ -230,6 +244,7 @@ async def test_respawned_children_after_reserve_never_spend(
|
|||||||
restored = AgentCoordinator()
|
restored = AgentCoordinator()
|
||||||
await restored.restore(snap)
|
await restored.restore(snap)
|
||||||
assert restored.reserve_stopped is True
|
assert restored.reserve_stopped is True
|
||||||
|
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger, restored))
|
||||||
|
|
||||||
sessions: list[Any] = []
|
sessions: list[Any] = []
|
||||||
with patch("strix.core.hooks.get_global_report_state", return_value=ledger):
|
with patch("strix.core.hooks.get_global_report_state", return_value=ledger):
|
||||||
@@ -264,7 +279,6 @@ async def test_resumed_parked_root_after_reserve_is_renotified_and_finalizes(
|
|||||||
ledger = _FakeLedger()
|
ledger = _FakeLedger()
|
||||||
ledger.cost = 9.0
|
ledger.cost = 9.0
|
||||||
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET)
|
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET)
|
||||||
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger))
|
|
||||||
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
||||||
|
|
||||||
coordinator = AgentCoordinator()
|
coordinator = AgentCoordinator()
|
||||||
@@ -276,6 +290,7 @@ async def test_resumed_parked_root_after_reserve_is_renotified_and_finalizes(
|
|||||||
restored = AgentCoordinator()
|
restored = AgentCoordinator()
|
||||||
await restored.restore(snap)
|
await restored.restore(snap)
|
||||||
assert restored.reserve_stopped is True
|
assert restored.reserve_stopped is True
|
||||||
|
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger, restored))
|
||||||
|
|
||||||
root_session = open_agent_session("root", tmp_path / "agents.sqlite")
|
root_session = open_agent_session("root", tmp_path / "agents.sqlite")
|
||||||
with patch("strix.core.hooks.get_global_report_state", return_value=ledger):
|
with patch("strix.core.hooks.get_global_report_state", return_value=ledger):
|
||||||
@@ -312,10 +327,10 @@ async def test_interactive_budget_pause_then_user_message_extends_and_resumes(
|
|||||||
ledger = _FakeLedger()
|
ledger = _FakeLedger()
|
||||||
ledger.cost = 9.0
|
ledger.cost = 9.0
|
||||||
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET, interactive=True)
|
hooks = ReportUsageHooks(model="test-model", max_budget_usd=MAX_BUDGET, interactive=True)
|
||||||
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger))
|
coordinator = AgentCoordinator()
|
||||||
|
monkeypatch.setattr(execution, "Runner", _fake_runner(ledger, coordinator))
|
||||||
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
monkeypatch.setattr(execution, "_compact_session", _noop_compact)
|
||||||
|
|
||||||
coordinator = AgentCoordinator()
|
|
||||||
coordinator.set_budget_extender(hooks.extend_budget)
|
coordinator.set_budget_extender(hooks.extend_budget)
|
||||||
await coordinator.register("root", "strix", parent_id=None)
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
root_session = open_agent_session("root", tmp_path / "agents.sqlite")
|
root_session = open_agent_session("root", tmp_path / "agents.sqlite")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Any, cast
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from agents.exceptions import MaxTurnsExceeded
|
||||||
from agents.items import MessageOutputItem
|
from agents.items import MessageOutputItem
|
||||||
from agents.memory import SQLiteSession
|
from agents.memory import SQLiteSession
|
||||||
from agents.tool_context import ToolContext
|
from agents.tool_context import ToolContext
|
||||||
@@ -785,3 +786,305 @@ async def test_run_agent_loop_seeds_identity_before_first_cycle(
|
|||||||
stored = await session.get_items()
|
stored = await session.get_items()
|
||||||
assert any("recon" in str(cast("dict[str, Any]", i).get("content", "")) for i in stored)
|
assert any("recon" in str(cast("dict[str, Any]", i).get("content", "")) for i in stored)
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _scripted_cycle(
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
|
agent_id: str,
|
||||||
|
statuses: list[str],
|
||||||
|
calls: list[Any],
|
||||||
|
) -> Any:
|
||||||
|
"""Fake run cycle that leaves ``agent_id`` in a scripted status per call."""
|
||||||
|
|
||||||
|
async def _cycle(*_args: Any, **kwargs: Any) -> Any:
|
||||||
|
calls.append(kwargs.get("input_data"))
|
||||||
|
status = statuses[min(len(calls) - 1, len(statuses) - 1)]
|
||||||
|
await coordinator.set_status(agent_id, status)
|
||||||
|
return MagicMock(final_output="plain text, no tool call")
|
||||||
|
|
||||||
|
return _cycle
|
||||||
|
|
||||||
|
|
||||||
|
async def _drive(
|
||||||
|
coordinator: AgentCoordinator,
|
||||||
|
agent_id: str,
|
||||||
|
*,
|
||||||
|
interactive: bool,
|
||||||
|
max_turns: int = 5,
|
||||||
|
) -> Any:
|
||||||
|
return await execution._run_until_lifecycle(
|
||||||
|
MagicMock(),
|
||||||
|
coordinator,
|
||||||
|
agent_id,
|
||||||
|
initial_input=[],
|
||||||
|
run_config=MagicMock(),
|
||||||
|
context={"agent_id": agent_id, "parent_id": None},
|
||||||
|
max_turns=max_turns,
|
||||||
|
session=None,
|
||||||
|
interactive=interactive,
|
||||||
|
event_sink=None,
|
||||||
|
hooks=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interactive_text_only_turn_is_nudged_instead_of_parking(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A no-tool-call turn must not silently hand control back to the user."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "root", ["running", "completed"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "root", interactive=True)
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
# The retry carries an explicit "call a tool" nudge rather than empty input.
|
||||||
|
nudge = calls[1][0]["content"]
|
||||||
|
assert "without a tool call" in nudge
|
||||||
|
assert "respond_to_user" in nudge
|
||||||
|
assert coordinator.statuses["root"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interactive_explicit_park_gets_no_nudge(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""``waiting`` is only reachable via respond_to_user / wait_for_agents."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "root", ["waiting"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "root", interactive=True)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert coordinator.statuses["root"] == "waiting"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interactive_recovery_exhaustion_parks_instead_of_crashing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A human can resume an interactive scan, so exhaustion parks rather than dies."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "root", ["running"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "root", interactive=True)
|
||||||
|
|
||||||
|
assert len(calls) == execution._INTERACTIVE_TOOL_RECOVERY_LIMIT
|
||||||
|
assert coordinator.statuses["root"] == "waiting"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interactive_subagent_exhaustion_tells_its_parent(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A parked child must report up so its parent stops waiting on it.
|
||||||
|
|
||||||
|
The parent is an agent, not a watching human, so a parent blocked in
|
||||||
|
wait_for_agents otherwise burns its whole timeout on a completion
|
||||||
|
report the child can no longer send.
|
||||||
|
"""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.register("child", "recon", parent_id="root")
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "child", ["running"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "child", interactive=True)
|
||||||
|
|
||||||
|
assert coordinator.statuses["child"] == "waiting"
|
||||||
|
pending, items = await coordinator.consume_pending("root", include_items=True)
|
||||||
|
assert pending == 1
|
||||||
|
notice = str(items[0])
|
||||||
|
assert "child" in notice
|
||||||
|
assert "parked" in notice
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interactive_root_exhaustion_notifies_nobody(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The root has no parent to report to, so parking stays silent."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "root", ["running"], []),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "root", interactive=True)
|
||||||
|
|
||||||
|
pending, _ = await coordinator.consume_pending("root")
|
||||||
|
assert pending == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_noninteractive_recovery_exhaustion_crashes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""No user is present to resume an autonomous run, so it still fails loudly."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle",
|
||||||
|
_scripted_cycle(coordinator, "root", ["running"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(MaxTurnsExceeded):
|
||||||
|
await _drive(coordinator, "root", interactive=False, max_turns=2)
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
assert coordinator.statuses["root"] == "crashed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tool_required_message_is_persisted_to_the_session(tmp_path: Any) -> None:
|
||||||
|
session = SQLiteSession("root", tmp_path / "agents.db")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await execution._append_tool_required_message(
|
||||||
|
session=session,
|
||||||
|
context={"parent_id": None},
|
||||||
|
attempt=1,
|
||||||
|
limit=3,
|
||||||
|
interactive=True,
|
||||||
|
)
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
|
||||||
|
stored = [cast("dict[str, Any]", i) for i in await session.get_items()]
|
||||||
|
assert "finish_scan" in stored[0]["content"]
|
||||||
|
assert "respond_to_user" in stored[0]["content"]
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovery_count_survives_a_snapshot_round_trip(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A resumed agent must not earn a fresh nudge budget and loop forever."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "root", ["running"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "root", interactive=True)
|
||||||
|
assert coordinator.recovery_counts["root"] == execution._INTERACTIVE_TOOL_RECOVERY_LIMIT
|
||||||
|
|
||||||
|
restored = AgentCoordinator()
|
||||||
|
await restored.restore(await coordinator.snapshot())
|
||||||
|
assert restored.recovery_counts["root"] == execution._INTERACTIVE_TOOL_RECOVERY_LIMIT
|
||||||
|
|
||||||
|
# The restored agent is already at its cap, so it parks after a single
|
||||||
|
# further text-only cycle instead of starting the whole budget over.
|
||||||
|
resumed_calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(restored, "root", ["running"], resumed_calls),
|
||||||
|
)
|
||||||
|
await _drive(restored, "root", interactive=True)
|
||||||
|
|
||||||
|
assert len(resumed_calls) == 1
|
||||||
|
assert restored.statuses["root"] == "waiting"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovery_count_is_cleared_by_a_lifecycle_tool(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
calls: list[Any] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
execution,
|
||||||
|
"_run_cycle_parked",
|
||||||
|
_scripted_cycle(coordinator, "root", ["running", "completed"], calls),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _drive(coordinator, "root", interactive=True)
|
||||||
|
|
||||||
|
assert "root" not in coordinator.recovery_counts
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_awaiting_a_human_is_never_auto_resumed() -> None:
|
||||||
|
"""The user can message any agent, so parking for one is not root-only."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.register("child", "recon", parent_id="root")
|
||||||
|
|
||||||
|
for agent_id in ("root", "child"):
|
||||||
|
await coordinator.park_waiting(agent_id, wait_kind="user")
|
||||||
|
assert await execution._plain_waiting_timeout(coordinator, agent_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_agent_awaiting_other_agents_is_re_checked_on_a_timer() -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.park_waiting("root", wait_kind="agents")
|
||||||
|
|
||||||
|
timeout = await execution._plain_waiting_timeout(coordinator, "root")
|
||||||
|
assert timeout == execution._WAITING_AUTO_RESUME_TIMEOUT_S
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_idle_auto_resumes_stop_after_their_budget() -> None:
|
||||||
|
"""A wedged agent must not burn a model turn per timeout for the whole scan."""
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("child", "recon", parent_id="root")
|
||||||
|
await coordinator.park_waiting("child", wait_kind="agents")
|
||||||
|
|
||||||
|
for _ in range(execution._MAX_IDLE_AUTO_RESUMES):
|
||||||
|
assert await execution._plain_waiting_timeout(coordinator, "child") is not None
|
||||||
|
await coordinator.record_idle_resume("child")
|
||||||
|
|
||||||
|
assert await execution._plain_waiting_timeout(coordinator, "child") is None
|
||||||
|
|
||||||
|
# A real message is real progress, so the budget starts over.
|
||||||
|
await coordinator.reset_idle_resumes("child")
|
||||||
|
assert await execution._plain_waiting_timeout(coordinator, "child") is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_kind_survives_a_snapshot_round_trip() -> None:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
await coordinator.park_waiting("root", wait_kind="user")
|
||||||
|
await coordinator.record_idle_resume("root")
|
||||||
|
|
||||||
|
restored = AgentCoordinator()
|
||||||
|
await restored.restore(await coordinator.snapshot())
|
||||||
|
|
||||||
|
assert restored.wait_kinds["root"] == "user"
|
||||||
|
assert restored.idle_resume_counts["root"] == 1
|
||||||
|
assert await execution._plain_waiting_timeout(restored, "root") is None
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Tests for the ``respond_to_user`` yield tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from agents.tool_context import ToolContext
|
||||||
|
|
||||||
|
from strix.core.agents import AgentCoordinator
|
||||||
|
from strix.tools.respond.tool import respond_to_user
|
||||||
|
|
||||||
|
|
||||||
|
async def _call(context: dict[str, Any], message: str = "here is what I found") -> dict[str, Any]:
|
||||||
|
ctx = ToolContext(
|
||||||
|
context=context,
|
||||||
|
tool_name="respond_to_user",
|
||||||
|
tool_call_id="call-1",
|
||||||
|
tool_arguments="{}",
|
||||||
|
)
|
||||||
|
raw = await respond_to_user.on_invoke_tool(ctx, json.dumps({"message": message}))
|
||||||
|
return json.loads(raw) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
async def _context(*, interactive: bool, agent_id: str = "root") -> dict[str, Any]:
|
||||||
|
coordinator = AgentCoordinator()
|
||||||
|
await coordinator.register("root", "strix", parent_id=None)
|
||||||
|
return {"coordinator": coordinator, "agent_id": agent_id, "interactive": interactive}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parks_the_agent_and_carries_the_message() -> None:
|
||||||
|
context = await _context(interactive=True)
|
||||||
|
result = await _call(context)
|
||||||
|
|
||||||
|
coordinator = context["coordinator"]
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["wait_outcome"] == "waiting"
|
||||||
|
assert result["message"] == "here is what I found"
|
||||||
|
assert coordinator.statuses["root"] == "waiting"
|
||||||
|
# Recorded as a human wait, so the driver never auto-resumes it.
|
||||||
|
assert coordinator.wait_kinds["root"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejected_in_an_autonomous_run() -> None:
|
||||||
|
context = await _context(interactive=False)
|
||||||
|
result = await _call(context)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "finish_scan" in result["error"]
|
||||||
|
assert context["coordinator"].statuses["root"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_message_that_already_arrived_is_taken_instead_of_parking() -> None:
|
||||||
|
context = await _context(interactive=True)
|
||||||
|
coordinator = context["coordinator"]
|
||||||
|
await coordinator.send("root", {"from": "user", "content": "wait, one more thing"})
|
||||||
|
|
||||||
|
result = await _call(context)
|
||||||
|
|
||||||
|
assert result["wait_outcome"] == "message_arrived"
|
||||||
|
assert result["pending_messages"] == 1
|
||||||
|
assert coordinator.statuses["root"] == "running"
|
||||||
Reference in New Issue
Block a user