Agents graph sweep: status taxonomy, stop_agent safety, skill validation
- view_agent_graph status summary now derives buckets from the canonical
Status literal via get_args, so adding a new status in core.agents
auto-flows into the summary. The previous hardcoded five-bucket list
silently omitted "failed" — buckets stopped summing to total whenever
an agent failed.
- stop_agent rejects targets that are already in a terminal status
(completed / stopped / crashed / failed) with a model-readable error
pointing at view_agent_graph and send_message_to_agent. request_stop
unconditionally overwrites status, so without this guard calling
stop_agent on a completed agent erased the "completed" history.
- StopAgentRenderer added — was falling back to the generic key/value
renderer; the rest of the agents_graph tools have purpose-built ones.
- agent_finish root-rejection payload trimmed from
{success, agent_completed, error, parent_notified} to {success, error}.
The lifecycle gate only reads success+agent_completed and they were
always False/False on this branch, so the extra fields were dead weight.
- wait_for_message renames its top-level outcome field from "status" to
"wait_outcome" — "status" overloaded with the coordinator's agent
status literal (which also has "stopped" as a value, different
meaning). Redundant "agent_waiting" boolean dropped (true iff
wait_outcome == "waiting"). Consumer at factory._wait_tool_parked
updated to match.
- send_message_to_agent now refuses self-send with a pointer at think /
agent_finish / finish_scan instead of looping a message into your
own session.
- SendMessageToAgentRenderer read args.get("agent_id") but the tool's
param is target_agent_id, so the TUI silently never showed the target.
Fixed.
- Restored skill validation lost during the SDK migration: skills
module re-exports get_all_skill_names and validate_requested_skills
(excluding internal scan_modes/coordination categories from the
user-selectable set). create_agent now validates skills before
spawning instead of silently accepting unknown names.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,20 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from agents import RunContextWrapper, function_tool
|
||||
|
||||
from strix.core.agents import coordinator_from_context
|
||||
from strix.core.agents import Status, coordinator_from_context
|
||||
from strix.skills import validate_requested_skills
|
||||
|
||||
|
||||
# An agent is "active" when stop_agent can meaningfully act on it. Anything
|
||||
# else (completed / stopped / crashed / failed) is terminal — request_stop
|
||||
# would forcibly overwrite that status, erasing the original outcome.
|
||||
_ACTIVE_STATUSES: frozenset[str] = frozenset({"running", "waiting"})
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -107,14 +115,13 @@ async def view_agent_graph(ctx: RunContextWrapper) -> str:
|
||||
for root in roots:
|
||||
render(root, 0)
|
||||
|
||||
summary = {
|
||||
"total": len(parent_of),
|
||||
"running": sum(1 for s in statuses.values() if s == "running"),
|
||||
"waiting": sum(1 for s in statuses.values() if s == "waiting"),
|
||||
"completed": sum(1 for s in statuses.values() if s == "completed"),
|
||||
"crashed": sum(1 for s in statuses.values() if s == "crashed"),
|
||||
"stopped": sum(1 for s in statuses.values() if s == "stopped"),
|
||||
}
|
||||
# Derive per-status counts from the canonical ``Status`` literal so a
|
||||
# new status added in core.agents auto-flows into this summary without
|
||||
# the buckets silently going out of sync with the source of truth.
|
||||
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,
|
||||
@@ -169,6 +176,18 @@ async def send_message_to_agent(
|
||||
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(
|
||||
@@ -263,7 +282,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"status": "stopped",
|
||||
"wait_outcome": "stopped",
|
||||
"reason": reason,
|
||||
"note": "Wait ended because this agent is stopped.",
|
||||
},
|
||||
@@ -277,7 +296,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"status": "message_arrived",
|
||||
"wait_outcome": "message_arrived",
|
||||
"pending_messages": pending,
|
||||
"messages": _session_items_payload(items),
|
||||
"reason": reason,
|
||||
@@ -291,8 +310,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"status": "waiting",
|
||||
"agent_waiting": True,
|
||||
"wait_outcome": "waiting",
|
||||
"reason": reason,
|
||||
"note": "Agent parked; execution will resume when a message arrives.",
|
||||
},
|
||||
@@ -308,7 +326,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"status": "timeout",
|
||||
"wait_outcome": "timeout",
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"reason": reason,
|
||||
"note": "No messages within timeout — continue work or call agent_finish.",
|
||||
@@ -323,7 +341,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"status": "stopped",
|
||||
"wait_outcome": "stopped",
|
||||
"reason": reason,
|
||||
"note": "Wait ended because this agent is stopped.",
|
||||
},
|
||||
@@ -337,7 +355,7 @@ async def wait_for_message( # noqa: PLR0911
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"status": "message_arrived",
|
||||
"wait_outcome": "message_arrived",
|
||||
"pending_messages": pending,
|
||||
"messages": _session_items_payload(items),
|
||||
"reason": reason,
|
||||
@@ -415,6 +433,15 @@ async def create_agent(
|
||||
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,
|
||||
)
|
||||
|
||||
# ``ctx.turn_input`` carries the parent's full conversation up to and
|
||||
# including the call that's currently invoking ``create_agent``.
|
||||
parent_history = list(ctx.turn_input) if inherit_context and ctx.turn_input else []
|
||||
@@ -423,7 +450,7 @@ async def create_agent(
|
||||
parent_ctx=inner,
|
||||
name=name,
|
||||
task=task,
|
||||
skills=list(skills or []),
|
||||
skills=skill_list,
|
||||
parent_history=parent_history,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -439,7 +466,7 @@ async def create_agent(
|
||||
result.get("agent_id"),
|
||||
name,
|
||||
parent_id or "-",
|
||||
len(skills or []),
|
||||
len(skill_list),
|
||||
len(task or ""),
|
||||
)
|
||||
|
||||
@@ -508,11 +535,9 @@ async def agent_finish(
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"agent_completed": False,
|
||||
"error": (
|
||||
"agent_finish is for subagents. Root/main agents must call finish_scan instead."
|
||||
),
|
||||
"parent_notified": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
default=str,
|
||||
@@ -621,6 +646,25 @@ async def stop_agent(
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user