feat: multi-level agent management — background agents, lifecycle tools, 3-level hierarchy
agent_manager.py (new): - AgentRecord dataclass: agent_id, level (1/2/3), role, task, status, started, parent_id (lineage), finished, result, notify, _task_ref - register() / finish() / cancel_agent() / list_agents() / get() / set_task_ref() - Calls notification.notify() on completion when notify=True (same channel as reminders and cron completions) - 24-hour pruning of completed records on each new registration spawn_agent (tools/agents.py): - background=True: fires asyncio.create_task(), registers in agent_manager, returns agent_id string immediately — sync path unchanged (no regression) - notify=True: push/Talk notification when the background task completes - Level enforcement: _agent_level param tracks hierarchy depth; when spawning from Level 2, child automatically gets spawn_agent + aider_run denied so Level 3 agents cannot delegate further New lifecycle tools (tools/agents.py + __init__.py): - agent_status(agent_id) — status, role, level, elapsed, task, result preview; user-level - agent_list(status, limit) — all agents for current user, newest first; user-level - agent_cancel(agent_id) — kills background task; admin-only, confirm-required tests/test_agent_manager.py (new, 41 tests): - agent_manager CRUD, pruning, notification hook - spawn_agent background: returns immediately, completes async, timeout, failure - Level enforcement: L1→L2 permits spawn, L2→L3 auto-denies; explicit tool_list path - agent_status / agent_list / agent_cancel output formatting - aider_run background: returns agent_id, completes async, sync path unchanged - All tests run without browser or Cortex service (~2.5s total) Run: cd cortex && .venv/bin/python -m pytest tests/test_agent_manager.py -v Docs: ARCH__FUTURE.md §13 (full design), ROADMAP.md, TODO__Agents.md, MASTER.md, HELP.md (orchestrator description corrected, tool schema line updated to reflect keyword routing), CLAUDE.md tool count 66→69. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,25 @@
|
||||
"""
|
||||
Agent spawning tool — lets the orchestrator launch sub-agents synchronously.
|
||||
Agent spawning and lifecycle tools.
|
||||
|
||||
Sub-agents run using the model assigned to the specified role. The call blocks
|
||||
until the sub-agent completes or times out.
|
||||
spawn_agent — synchronous or background sub-agent via any configured role model.
|
||||
agent_status / agent_list / agent_cancel — lifecycle management for background agents.
|
||||
|
||||
Supported model types: local_openai, gemini_api.
|
||||
claude_cli / gemini_cli are chat-only and do not support sub-agent tool loops.
|
||||
Sub-agents run using the model and tools assigned to the given role. The three-level
|
||||
hierarchy (Persona → Specialized → Support) is enforced by denying spawn_agent and
|
||||
aider_run at the L2→L3 boundary — Level 3 agents cannot delegate further.
|
||||
|
||||
Supported model types for sub-agents: local_openai, gemini_api.
|
||||
claude_cli / gemini_cli are chat-only and do not support tool-enabled sub-agents.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from google.genai import types
|
||||
|
||||
import agent_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-host semaphores — keyed by "host:<host_id>" or "type:<model_type>"
|
||||
@@ -20,6 +27,9 @@ logger = logging.getLogger(__name__)
|
||||
_semaphores: dict[str, asyncio.Semaphore] = {}
|
||||
_sem_lock = asyncio.Lock()
|
||||
|
||||
# Tools denied at the L2→L3 boundary so Level 3 agents cannot delegate further.
|
||||
_L3_DENY_TOOLS = ["spawn_agent", "aider_run"]
|
||||
|
||||
|
||||
async def _get_semaphore(key: str, max_concurrent: int) -> asyncio.Semaphore:
|
||||
"""Return (or create) the semaphore for a given host/type key."""
|
||||
@@ -37,12 +47,23 @@ async def spawn_agent(
|
||||
max_rounds: int | None = None,
|
||||
allow_tools: list[str] | None = None,
|
||||
deny_tools: list[str] | None = None,
|
||||
background: bool = False,
|
||||
notify: bool = False,
|
||||
_agent_level: int = 2,
|
||||
) -> str:
|
||||
"""
|
||||
Spawn a sub-agent to complete a task synchronously.
|
||||
Spawn a sub-agent to complete a task.
|
||||
|
||||
The sub-agent uses the model and tools assigned to the given role. Returns
|
||||
the sub-agent's response as a string.
|
||||
In synchronous mode (background=False, the default): blocks until done and returns
|
||||
the result string.
|
||||
|
||||
In background mode (background=True): registers the agent, fires it as an asyncio
|
||||
background task, and returns an agent_id string immediately. Use agent_status() to
|
||||
poll, or set notify=True to receive a push notification on completion.
|
||||
|
||||
Level enforcement: this agent (level _agent_level) spawns children at level+1.
|
||||
Children at level 3 automatically have spawn_agent and aider_run denied so they
|
||||
cannot delegate further.
|
||||
"""
|
||||
import model_registry
|
||||
from context_loader import load_context
|
||||
@@ -105,9 +126,18 @@ async def spawn_agent(
|
||||
if tool_list is not None:
|
||||
tool_list = [t for t in tool_list if t not in deny_set]
|
||||
else:
|
||||
# tool_list is unrestricted — block via confirm_deny so the gate fires
|
||||
confirm_deny = confirm_deny | deny_set
|
||||
|
||||
# Level enforcement: children of this agent are at level _agent_level + 1.
|
||||
# Level 3 children cannot delegate — auto-deny the spawning tools.
|
||||
child_level = _agent_level + 1
|
||||
if child_level >= 3:
|
||||
l3_deny = set(_L3_DENY_TOOLS)
|
||||
if tool_list is not None:
|
||||
tool_list = [t for t in tool_list if t not in l3_deny]
|
||||
else:
|
||||
confirm_deny = confirm_deny | l3_deny
|
||||
|
||||
if max_rounds is not None:
|
||||
model_cfg = dict(model_cfg)
|
||||
model_cfg["max_rounds"] = max_rounds
|
||||
@@ -158,6 +188,41 @@ async def spawn_agent(
|
||||
)
|
||||
return result.response or "(sub-agent returned no output)"
|
||||
|
||||
if background:
|
||||
rec = await agent_manager.register(
|
||||
user=user,
|
||||
role=role,
|
||||
task=task,
|
||||
level=_agent_level,
|
||||
notify=notify,
|
||||
)
|
||||
|
||||
async def _bg_task() -> None:
|
||||
async with sem:
|
||||
try:
|
||||
logger.info(
|
||||
"spawn_agent [bg]: %s role=%s level=%d timeout=%ds",
|
||||
rec.agent_id[:8], role, _agent_level, timeout,
|
||||
)
|
||||
result = await asyncio.wait_for(_run(), timeout=float(timeout))
|
||||
await agent_manager.finish(rec.agent_id, result, "done")
|
||||
logger.info("spawn_agent [bg]: done %s", rec.agent_id[:8])
|
||||
except asyncio.CancelledError:
|
||||
await agent_manager.finish(rec.agent_id, "Cancelled.", "cancelled")
|
||||
raise
|
||||
except asyncio.TimeoutError:
|
||||
msg = f"Sub-agent timed out after {timeout}s (role={role})"
|
||||
logger.warning("spawn_agent [bg]: timeout %s", rec.agent_id[:8])
|
||||
await agent_manager.finish(rec.agent_id, msg, "timeout")
|
||||
except Exception as e:
|
||||
logger.exception("spawn_agent [bg]: failed %s", rec.agent_id[:8])
|
||||
await agent_manager.finish(rec.agent_id, str(e), "failed")
|
||||
|
||||
bg = asyncio.create_task(_bg_task())
|
||||
agent_manager.set_task_ref(rec.agent_id, bg)
|
||||
return f"Agent started in background. ID: {rec.agent_id}\nUse agent_status('{rec.agent_id}') to check progress."
|
||||
|
||||
# Synchronous path — unchanged behaviour
|
||||
async with sem:
|
||||
try:
|
||||
logger.info(
|
||||
@@ -175,14 +240,84 @@ async def spawn_agent(
|
||||
return f"Sub-agent error ({role}): {e}"
|
||||
|
||||
|
||||
# ── Agent lifecycle tools ─────────────────────────────────────────────────────
|
||||
|
||||
async def agent_status(agent_id: str) -> str:
|
||||
"""Return the status and result preview of a background agent."""
|
||||
from persona import get_user
|
||||
user = get_user() or "unknown"
|
||||
rec = agent_manager.get(agent_id)
|
||||
if not rec:
|
||||
return f"No agent found with ID: {agent_id}"
|
||||
if rec.user != user:
|
||||
return "Access denied."
|
||||
|
||||
now = datetime.now()
|
||||
end = rec.finished or now
|
||||
elapsed = int((end - rec.started).total_seconds())
|
||||
|
||||
lines = [
|
||||
f"Agent {rec.agent_id[:8]}…",
|
||||
f" Status: {rec.status}",
|
||||
f" Role: {rec.role} (Level {rec.level})",
|
||||
f" Elapsed: {elapsed}s",
|
||||
f" Started: {rec.started.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f" Task: {rec.task}",
|
||||
]
|
||||
if rec.parent_id:
|
||||
lines.append(f" Parent: {rec.parent_id[:8]}…")
|
||||
if rec.result is not None:
|
||||
lines.append(f" Result: {rec.result[:300]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def agent_list(status: str | None = None, limit: int = 10) -> str:
|
||||
"""List background agents for the current user."""
|
||||
from persona import get_user
|
||||
user = get_user() or "unknown"
|
||||
limit = min(max(int(limit), 1), 50)
|
||||
records = agent_manager.list_agents(user, status=status, limit=limit)
|
||||
|
||||
if not records:
|
||||
suffix = f" (filter: status={status})" if status else ""
|
||||
return f"No agents found.{suffix}"
|
||||
|
||||
now = datetime.now()
|
||||
lines = []
|
||||
for rec in records:
|
||||
end = rec.finished or now
|
||||
elapsed = int((end - rec.started).total_seconds())
|
||||
preview = rec.task[:60].replace("\n", " ")
|
||||
result_hint = f" → {rec.result[:50]}" if rec.result else ""
|
||||
lines.append(
|
||||
f"[{rec.agent_id[:8]}] {rec.status:<10s} L{rec.level} "
|
||||
f"{rec.role:<12s} {elapsed:>5}s {preview}{result_hint}"
|
||||
)
|
||||
|
||||
header = f"{len(records)} agent(s)" + (f" (status={status})" if status else "") + ":"
|
||||
return header + "\n" + "\n".join(lines)
|
||||
|
||||
|
||||
async def agent_cancel(agent_id: str) -> str:
|
||||
"""Cancel a running background agent."""
|
||||
from persona import get_user
|
||||
user = get_user() or "unknown"
|
||||
return await agent_manager.cancel_agent(agent_id, user)
|
||||
|
||||
|
||||
# ── Declarations ──────────────────────────────────────────────────────────────
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="spawn_agent",
|
||||
description=(
|
||||
"Spawn a sub-agent to complete a task synchronously. "
|
||||
"Spawn a sub-agent to complete a task. "
|
||||
"In synchronous mode (default): blocks until the sub-agent finishes and returns its response. "
|
||||
"In background mode (background=True): fires the agent asynchronously and returns an agent_id "
|
||||
"immediately — use agent_status() to check progress or set notify=True for a completion alert. "
|
||||
"The sub-agent uses the model and tool set assigned to the given role. "
|
||||
"Use for processing pipelines, parallel analysis, or delegating "
|
||||
"specialized work (research, coding, data migration, etc.)."
|
||||
"Use for processing pipelines, parallel analysis, or delegating specialized work "
|
||||
"(research, coding, data migration, etc.)."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
@@ -209,7 +344,7 @@ DECLARATIONS = [
|
||||
),
|
||||
"timeout": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Max seconds to wait (default 120).",
|
||||
description="Max seconds to wait (default 120). Applies in both sync and background mode.",
|
||||
),
|
||||
"max_rounds": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
@@ -221,7 +356,6 @@ DECLARATIONS = [
|
||||
description=(
|
||||
"Restrict the sub-agent to only these tools. "
|
||||
"Intersected with the role's tool set — cannot grant more than the role allows. "
|
||||
"Omit to give the sub-agent the role's full tool set. "
|
||||
"Example: ['web_search', 'web_read'] for a pure research agent."
|
||||
),
|
||||
),
|
||||
@@ -230,12 +364,83 @@ DECLARATIONS = [
|
||||
items=types.Schema(type=types.Type.STRING),
|
||||
description=(
|
||||
"Block these tools from the sub-agent regardless of role config. "
|
||||
"Use to prevent destructive operations in sensitive sub-tasks. "
|
||||
"Example: ['shell_exec', 'file_write', 'cortex_restart']."
|
||||
),
|
||||
),
|
||||
"background": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Run asynchronously in the background (default: false). "
|
||||
"When true, returns an agent_id immediately instead of blocking for the result. "
|
||||
"Use agent_status(agent_id) to check progress. "
|
||||
"Best for tasks that take more than ~30 seconds."
|
||||
),
|
||||
),
|
||||
"notify": types.Schema(
|
||||
type=types.Type.BOOLEAN,
|
||||
description=(
|
||||
"Send a push/Talk notification when the background agent completes (default: false). "
|
||||
"Only meaningful when background=true."
|
||||
),
|
||||
),
|
||||
},
|
||||
required=["task"],
|
||||
),
|
||||
)
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="agent_status",
|
||||
description=(
|
||||
"Get the current status of a background agent by ID. "
|
||||
"Returns status (running/done/failed/cancelled/timeout), role, elapsed time, "
|
||||
"task description, and result preview."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"agent_id": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="The agent ID returned by spawn_agent(background=True) or aider_run(background=True).",
|
||||
),
|
||||
},
|
||||
required=["agent_id"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="agent_list",
|
||||
description=(
|
||||
"List background agents for the current user. "
|
||||
"Returns recent agents with ID, status, role, level, elapsed time, and task preview. "
|
||||
"Use to survey what's running or recently completed."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"status": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="Filter by status: 'running', 'done', 'failed', 'cancelled', 'timeout'. Omit for all.",
|
||||
),
|
||||
"limit": types.Schema(
|
||||
type=types.Type.INTEGER,
|
||||
description="Max agents to return (default 10, max 50).",
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="agent_cancel",
|
||||
description=(
|
||||
"Cancel a running background agent. ADMIN ONLY. Requires confirmation. "
|
||||
"Use agent_list() to find the agent ID first."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"agent_id": types.Schema(
|
||||
type=types.Type.STRING,
|
||||
description="The agent ID to cancel.",
|
||||
),
|
||||
},
|
||||
required=["agent_id"],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user