Files
strix/strix/tools/agents_graph/tools.py
T
Ahmed Allam 8b464dae5d refactor(tools): split wait_for_message into respond_to_user + wait_for_agents
One tool was doing three jobs (wait on the user, wait on other agents, and
- wrongly - wait for a long-running command), so the driver had to guess which
one an agent meant and used parent_id as the proxy: the root waits for a human,
everyone else waits for agents. That proxy is wrong, since the user can message
any agent from the TUI's agent tree.

Tool identity now carries the intent, and the coordinator records it as a
wait_kind that survives snapshot/restore:

  respond_to_user  -> wait_kind="user",   never auto-resumed (root or not)
  wait_for_agents  -> wait_kind="agents", auto-resumed on a 300s timer
  recovery exhaust -> wait_kind="stalled"

respond_to_user fuses the message and the yield into one call, so there is no
way to answer and then forget to stop - the two-step that gpt-4o-mini skipped
2/2 in live testing. Plain text still renders as before.

Auto-resume is also bounded now: an agent that re-parks after every timeout
burned a model turn every 300s for the rest of the scan (and, since parked
children notify their parent, spammed the parent's inbox on the same cycle).
After _MAX_IDLE_AUTO_RESUMES it stays parked until a real message arrives.
2026-08-01 22:13:07 +00:00

704 lines
24 KiB
Python

"""Multi-agent graph tools backed by AgentCoordinator."""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections import Counter
from datetime import UTC, datetime
from typing import Any, Literal, get_args
from agents import RunContextWrapper, function_tool
from strix.core.agents import Status, coordinator_from_context
from strix.skills import validate_requested_skills
_ACTIVE_STATUSES: frozenset[str] = frozenset({"running", "waiting"})
logger = logging.getLogger(__name__)
def _ctx(ctx: RunContextWrapper) -> dict[str, Any]:
return ctx.context if isinstance(ctx.context, dict) else {}
def _render_completion_report(
*,
agent_name: str,
agent_id: str,
task: str,
success: bool,
result_summary: str,
findings: list[str],
recommendations: list[str],
) -> str:
"""Render a child's completion report as plain structured text.
Goes into the parent's SDK session with coordinator-added sender
metadata, so this body just carries the contents. No XML — no
escaping concerns, no parser ambiguity.
"""
status = "SUCCESS" if success else "FAILED"
completion_time = datetime.now(UTC).isoformat()
lines: list[str] = [
f"== Completion report from {agent_name} ({agent_id}) ==",
f"Status: {status}",
f"Time: {completion_time}",
]
if task:
lines.append(f"Task: {task}")
lines.append("")
lines.append("Summary:")
lines.append(result_summary or "(none)")
if findings:
lines.append("")
lines.append("Findings:")
lines.extend(f"- {f}" for f in findings)
if recommendations:
lines.append("")
lines.append("Recommendations:")
lines.extend(f"- {r}" for r in recommendations)
return "\n".join(lines)
@function_tool(timeout=30)
async def view_agent_graph(ctx: RunContextWrapper) -> str:
"""Print the multi-agent tree — every agent, its parent, its status.
Use before spawning a new agent (don't duplicate work — check whether
something specialized for that task already exists) and any time you
want a snapshot of who's still ``running`` / ``waiting`` /
``completed`` / ``crashed`` / ``stopped``. Output is an indented
bullet list with status in brackets; the agent that called this tool
is marked ``← you``.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
if coordinator is None:
return json.dumps(
{"success": False, "error": "Agent coordinator not initialized in context"},
ensure_ascii=False,
default=str,
)
parent_of, statuses, names, _ = await coordinator.graph_snapshot()
lines: list[str] = []
def render(aid: str, depth: int) -> None:
status = statuses.get(aid, "?")
marker = " ← you" if aid == me else ""
lines.append(f"{' ' * depth}- {names.get(aid, aid)} ({aid}) [{status}]{marker}")
for child, p in parent_of.items():
if p == aid:
render(child, depth + 1)
roots = [aid for aid, parent in parent_of.items() if parent is None]
for root in roots:
render(root, 0)
counts = Counter(statuses.values())
summary: dict[str, int] = {"total": len(parent_of)}
for status_name in get_args(Status):
summary[status_name] = counts.get(status_name, 0)
return json.dumps(
{
"success": True,
"graph_structure": "\n".join(lines) or "(no agents)",
"summary": summary,
},
ensure_ascii=False,
default=str,
)
@function_tool(timeout=30)
async def send_message_to_agent(
ctx: RunContextWrapper,
target_agent_id: str,
message: str,
message_type: Literal["query", "instruction", "information"] = "information",
priority: Literal["low", "normal", "high", "urgent"] = "normal",
) -> str:
"""Send a message to another agent's inbox — sparingly.
Inter-agent messages are appended to the target's SDK session and
interrupt any active target turn so the next run cycle sees them.
Use only when essential:
- Sharing a discovered finding/credential another agent needs.
- Asking a specialist a focused question.
- Coordinating who covers what (avoid overlap).
- Telling a child to wrap up or change course.
**Don't** use for routine "hello/status" pings, for context the
target already has (children inherit parent history), or when
parent/child completion via ``agent_finish`` already covers the
flow. Messages to any registered agent wake it, regardless of
status, so a follow-up can restart a completed/stopped/failed agent.
Args:
target_agent_id: Recipient's 8-char id.
message: The full message body. Be specific — include payloads,
URLs, or what you want them to do, not just headlines.
message_type: ``query`` (you want a reply), ``instruction``
(you're directing them), ``information`` (FYI, no reply
expected). Default ``information``.
priority: ``low`` / ``normal`` / ``high`` / ``urgent``.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
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 target_agent_id == me:
return json.dumps(
{
"success": False,
"error": (
"Cannot send a message to yourself; use `think` to record a "
"private note, or `agent_finish` / `finish_scan` to terminate"
),
},
ensure_ascii=False,
default=str,
)
msg_id = f"msg_{uuid.uuid4().hex[:8]}"
delivered = await coordinator.send(
target_agent_id,
{
"id": msg_id,
"from": me,
"content": message,
"type": message_type,
"priority": priority,
},
)
if not delivered:
return json.dumps(
{
"success": False,
"error": f"Target agent '{target_agent_id}' not found or message delivery failed",
},
ensure_ascii=False,
default=str,
)
return json.dumps(
{
"success": True,
"message_id": msg_id,
"target_agent_id": target_agent_id,
"delivery_status": "delivered",
},
ensure_ascii=False,
default=str,
)
def _session_items_payload(items: list[Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
for item in items:
if isinstance(item, dict):
role = item.get("role")
content = item.get("content")
payload.append({"role": role, "content": content})
else:
payload.append({"content": str(item)})
return payload
_WAIT_DEFAULT_TIMEOUT_S = 300
# 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,
reason: str = "Waiting for messages from other agents",
timeout_seconds: int = _WAIT_DEFAULT_TIMEOUT_S,
) -> str:
"""Pause until another AGENT messages you (or the timeout elapses).
Use when you have nothing useful to do until a child or peer
responds — typically after spawning subagents and you want their
completion reports. You resume the instant any message arrives, so
size ``timeout_seconds`` to the 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:**
- **Never** call this if you have no agents left to hear from —
that just strands you until the timeout. Call ``finish_scan``
(root) or ``agent_finish`` (subagent) instead.
- 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
reason to send to your inbox and you'll wait the full timeout.
- Children update the parent automatically via ``agent_finish``
→ no extra coordination needed.
Args:
reason: One-line note shown in graph snapshots while you're
waiting (helps a human or sibling agent debug who's stuck
on what).
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
larger value never makes you wait longer when the reply does
come. Right-size it to what you're waiting on: a short wait
(e.g. 10-60s) for a quick ack or a small/fast subtask, and a
longer one (e.g. ~100-200s) only for genuinely long-running
work (deep recon, exploitation, a full sub-scan). The cap only
bites when the expected message never arrives — so an oversized
timeout on a trivial wait just strands you idle until it
elapses. On timeout the tool returns and you decide whether to
keep working or wait again.
"""
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,
)
async with coordinator._lock:
stopped = coordinator.statuses.get(me) == "stopped"
if stopped:
return json.dumps(
{
"success": True,
"wait_outcome": "stopped",
"reason": reason,
"note": "Wait ended because this agent is stopped.",
},
ensure_ascii=False,
default=str,
)
pending, items = await coordinator.consume_pending(me, include_items=True)
if pending > 0:
await coordinator.mark_running(me)
return json.dumps(
{
"success": True,
"wait_outcome": "message_arrived",
"pending_messages": pending,
"messages": _session_items_payload(items),
"reason": reason,
},
ensure_ascii=False,
default=str,
)
if interactive:
await coordinator.park_waiting(me, wait_kind="agents")
return json.dumps(
{
"success": True,
"wait_outcome": "waiting",
"reason": reason,
"note": "Agent parked; execution will resume when a message arrives.",
},
ensure_ascii=False,
default=str,
)
await coordinator.park_waiting(me, wait_kind="agents")
try:
await asyncio.wait_for(coordinator.wait_for_message(me), timeout_seconds)
except TimeoutError:
await coordinator.mark_running(me)
return json.dumps(
{
"success": True,
"wait_outcome": "timeout",
"timeout_seconds": timeout_seconds,
"reason": reason,
"note": "No messages within timeout — continue work or call agent_finish.",
},
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",
"reason": reason,
"note": "Wait ended because this agent is stopped.",
},
ensure_ascii=False,
default=str,
)
pending, items = await coordinator.consume_pending(me, include_items=True)
await coordinator.mark_running(me)
return json.dumps(
{
"success": True,
"wait_outcome": "message_arrived",
"pending_messages": pending,
"messages": _session_items_payload(items),
"reason": reason,
},
ensure_ascii=False,
default=str,
)
@function_tool(timeout=120)
async def create_agent(
ctx: RunContextWrapper,
name: str,
task: str,
inherit_context: bool = True,
skills: list[str] | None = None,
) -> str:
"""Spawn a specialist child agent to run in parallel.
Decompose complex pentests by handing focused subtasks to dedicated
children. The child runs asynchronously — the parent continues
immediately and can ``wait_for_agents`` later (or just keep
working in parallel). When the child calls ``agent_finish``, its
completion report lands in the parent's inbox.
**Before spawning, call ``view_agent_graph``** to confirm no
existing agent already covers this scope — duplicate specialists
waste turns and create coordination headaches.
**Specialization principles:**
- Most agents need at least one ``skill`` to be useful.
- Aim for **1-3 related skills** per agent. Up to 5 only when the
task genuinely spans them.
- One skill = most focused (e.g., XSS-only). Five skills = upper
bound.
- Match the ``name`` to the focus (``XSS Specialist``,
``SQLi Validator``, ``Auth Specialist``).
**When to spawn vs do it yourself:**
- Spawn when the subtask is large, parallelizable, or needs
different specialization than what you're already doing.
- Don't spawn for trivial one-shot probes — just run the tool
yourself.
Args:
name: Human-readable child name (used in graph views and
``send_message_to_agent`` flows).
task: Specific objective. Be concrete — what to test, what
success looks like, any constraints.
inherit_context: Default ``True``. The child receives the
parent's input history as background; only set ``False``
when starting a clean-slate task.
skills: List of skill names (e.g. ``["xss", "sql_injection"]``).
Max 5; prefer 1-3.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
parent_id = inner.get("agent_id")
spawner = inner.get("spawn_child_agent")
if coordinator is None or parent_id is None:
return json.dumps(
{"success": False, "error": "Agent coordinator or agent_id missing in context"},
ensure_ascii=False,
default=str,
)
if not callable(spawner):
return json.dumps(
{
"success": False,
"error": "Scan runner did not provide a child-agent spawner in context",
},
ensure_ascii=False,
default=str,
)
skill_list = list(skills or [])
skill_error = validate_requested_skills(skill_list)
if skill_error:
return json.dumps(
{"success": False, "error": skill_error, "agent_id": None},
ensure_ascii=False,
default=str,
)
parent_history = list(ctx.turn_input) if inherit_context and ctx.turn_input else []
try:
result = await spawner(
parent_ctx=inner,
name=name,
task=task,
skills=skill_list,
parent_history=parent_history,
)
except Exception as e:
logger.exception("create_agent: scan runner failed to spawn child '%s'", name)
return json.dumps(
{"success": False, "error": f"child spawn failed: {e!s}"},
ensure_ascii=False,
default=str,
)
logger.info(
"create_agent: spawned %s (%s) parent=%s skills=%d task_len=%d",
result.get("agent_id"),
name,
parent_id or "-",
len(skill_list),
len(task or ""),
)
return json.dumps(
result,
ensure_ascii=False,
default=str,
)
@function_tool(timeout=30)
async def agent_finish(
ctx: RunContextWrapper,
result_summary: str,
findings: list[str] | None = None,
success: bool = True,
report_to_parent: bool = True,
final_recommendations: list[str] | None = None,
) -> str:
"""Subagent termination — post a completion report to the parent.
**Subagents only.** Root agents must call ``finish_scan`` instead;
this tool refuses to run for root agents. Calling this:
1. Marks the subagent as ``completed``.
2. Posts a structured completion report to the parent's inbox
(when ``report_to_parent`` is true).
3. Stops this subagent's execution.
**Vulnerability findings must already be filed via
``create_vulnerability_report`` (or ``create_dependency_report``
for known-CVE dependency/supply-chain findings) before calling
this.** The ``findings`` field here is for narrative summary only
— it does not register vulns in the scan report.
Write the summary as if the parent has no idea what you were
doing: what did you test, what did you find/confirm/rule out,
what's still open.
Args:
result_summary: What you accomplished and discovered. Concrete
and specific (URLs, parameters, payloads that worked).
findings: Optional bullet list of confirmed observations. For
credit-bearing vulnerabilities, file
``create_vulnerability_report`` first (or
``create_dependency_report`` for dependency CVEs); this is
for narrative.
success: Whether the assigned subtask was completed
successfully. Default ``True``.
report_to_parent: Whether to deliver the completion report to
the parent's inbox. Default ``True``.
final_recommendations: Optional next-step suggestions for the
parent (e.g., "prioritize testing X", "spawn an agent to
cover Y").
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
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,
)
parent_id = inner.get("parent_id")
if parent_id is None:
return json.dumps(
{
"success": False,
"error": (
"agent_finish is for subagents. Root/main agents must call finish_scan instead"
),
},
ensure_ascii=False,
default=str,
)
parent_notified = False
if report_to_parent:
async with coordinator._lock:
agent_name = coordinator.names.get(me, me)
report = _render_completion_report(
agent_name=agent_name,
agent_id=me,
task=str(inner.get("task", "")),
success=success,
result_summary=result_summary,
findings=list(findings or []),
recommendations=list(final_recommendations or []),
)
await coordinator.send(
parent_id,
{
"id": f"report_{uuid.uuid4().hex[:8]}",
"from": me,
"content": report,
"type": "completion",
"priority": "high",
},
)
parent_notified = True
logger.info(
"agent_finish: %s success=%s findings=%d parent_notified=%s",
me,
success,
len(findings or []),
parent_notified,
)
await coordinator.set_status(me, "completed")
return json.dumps(
{
"success": True,
"agent_completed": True,
"parent_notified": parent_notified,
"agent_id": me,
"summary": result_summary,
"findings_count": len(findings or []),
"has_recommendations": bool(final_recommendations),
},
ensure_ascii=False,
default=str,
)
@function_tool(timeout=30)
async def stop_agent(
ctx: RunContextWrapper,
target_agent_id: str,
cascade: bool = True,
reason: str = "",
) -> str:
"""Gracefully stop a running agent (and optionally its descendants).
Uses the SDK's ``RunResultStreaming.cancel(mode="after_turn")`` so the
target's current turn finishes — including saving items to its
session — before the run loop honors the cancel. The agent's
interactive outer loop parks as ``stopped``; later user/peer
messages can wake it again.
Use sparingly. Prefer ``send_message_to_agent`` (asking the agent
to wrap up) for soft-stop scenarios. Reach for ``stop_agent`` when
a child has gone off-track and won't self-correct.
Args:
target_agent_id: The 8-char id from ``view_agent_graph`` /
``create_agent``. Cannot stop yourself.
cascade: If ``True`` (default), also stop every descendant of
``target_agent_id`` leaves-first. ``False`` stops only the
target.
reason: Optional human-readable reason for the stop, surfaced
in logs and telemetry.
"""
inner = _ctx(ctx)
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
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 target_agent_id == me:
return json.dumps(
{
"success": False,
"error": "Cannot stop yourself; call agent_finish or finish_scan instead",
},
ensure_ascii=False,
default=str,
)
_, statuses, _, _ = await coordinator.graph_snapshot()
if target_agent_id not in statuses:
return json.dumps(
{"success": False, "error": f"Unknown agent_id: {target_agent_id}"},
ensure_ascii=False,
default=str,
)
current_status = statuses[target_agent_id]
if current_status not in _ACTIVE_STATUSES:
return json.dumps(
{
"success": False,
"error": (
f"Agent {target_agent_id} is already '{current_status}'; "
"stop_agent only acts on running/waiting agents — use "
"view_agent_graph to find still-active descendants and "
"stop them individually, or send_message_to_agent if you "
"want to wake this one with new instructions"
),
"target_agent_id": target_agent_id,
"current_status": current_status,
},
ensure_ascii=False,
default=str,
)
if cascade:
await coordinator.cancel_descendants_graceful(target_agent_id)
else:
await coordinator.request_stop(target_agent_id)
logger.info(
"stop_agent: target=%s cascade=%s reason=%r",
target_agent_id,
cascade,
reason,
)
return json.dumps(
{
"success": True,
"target_agent_id": target_agent_id,
"cascade": cascade,
"reason": reason,
"note": "Cancellation is graceful — current turn completes first.",
},
ensure_ascii=False,
default=str,
)