feat: spawn_agent tool + host max_concurrent + docs

Adds a synchronous sub-agent spawning tool that lets the orchestrator
delegate tasks to a specific role's model and tool set.

- cortex/tools/agents.py: spawn_agent(task, role, tier, timeout, max_rounds)
  - Supports local_openai and gemini_api model types
  - Per-host asyncio semaphore (keyed by host_id or model type)
  - asyncio.wait_for() enforces timeout; admin-only tool
- cortex/model_registry.py: max_concurrent field in host schema (default 3,
  clamped 1-20); backfilled on _normalize() for existing hosts
- cortex/routers/local_llm.py + local_llm.html: "Max parallel" number input
  in host add/edit forms
- cortex/tools/__init__.py: spawn_agent registered in TOOL_CATEGORIES["Agents"],
  _CALLABLES, TOOL_ROLES (admin), and _ALL_DECLARATIONS
- Docs: TOOLS.md count 44→45, spawn_agent section; HELP.md tool table updated;
  ARCH__FUTURE.md Round 2 completed items; TODO__Agents.md spawn_agent checked;
  CLAUDE.md tool count and list updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-08 22:48:21 -04:00
parent 6ad7597db8
commit 09d775b47b
10 changed files with 275 additions and 26 deletions

View File

@@ -189,6 +189,7 @@ def _normalize(data: dict) -> dict:
"""Back-fill missing fields introduced by schema additions."""
for h in data.get("hosts", []):
h.setdefault("host_type", "openwebui")
h.setdefault("max_concurrent", 3)
data.setdefault("providers", _default_providers())
data["providers"].setdefault("anthropic", {"credentials": [{"id": "cli", "label": "Claude CLI (OAuth)", "type": "cli"}]})
data["providers"].setdefault("google", {"accounts": []})
@@ -605,17 +606,20 @@ def remove_google_account(username: str, account_id: str) -> bool:
def save_host(username: str, host_id: str | None,
label: str, api_url: str, api_key: str,
host_type: str = "openwebui") -> str:
host_type: str = "openwebui",
max_concurrent: int = 3) -> str:
"""Create or update a host. Returns the host ID."""
data = _load(username)
host_type = host_type if host_type in ("openwebui", "openai") else "openwebui"
max_concurrent = max(1, min(int(max_concurrent), 20))
if host_id:
for h in data["hosts"]:
if h["id"] == host_id:
h["label"] = label.strip()
h["api_url"] = api_url.strip()
h["host_type"] = host_type
h["label"] = label.strip()
h["api_url"] = api_url.strip()
h["host_type"] = host_type
h["max_concurrent"] = max_concurrent
if api_key.strip():
h["api_key"] = api_key.strip()
_save(username, data)
@@ -624,11 +628,12 @@ def save_host(username: str, host_id: str | None,
host_id = secrets.token_hex(4)
data["hosts"].append({
"id": host_id,
"label": label.strip(),
"api_url": api_url.strip(),
"api_key": api_key.strip(),
"host_type": host_type,
"id": host_id,
"label": label.strip(),
"api_url": api_url.strip(),
"api_key": api_key.strip(),
"host_type": host_type,
"max_concurrent": max_concurrent,
})
_save(username, data)
return host_id

View File

@@ -114,6 +114,11 @@ def _render(username: str, success: str = "", error: str = "") -> str:
<option value="openai"{ai}>OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
<div class="field" style="flex:0 0 auto; width:6rem">
<label>Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20"
value="{h.get('max_concurrent', 3)}" style="width:100%">
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
@@ -421,19 +426,20 @@ async def remove_google_account(request: Request, account_id: str):
@router.post("/settings/local/host", include_in_schema=False)
async def save_host(
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
request: Request,
host_id: str = Form(""),
label: str = Form(""),
api_url: str = Form(""),
api_key: str = Form(""),
host_type: str = Form("openwebui"),
max_concurrent: int = Form(3),
):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not api_url.strip():
return HTMLResponse(_render(username, error="API URL is required."))
reg.save_host(username, host_id or None, label, api_url, api_key, host_type)
reg.save_host(username, host_id or None, label, api_url, api_key, host_type, max_concurrent)
return HTMLResponse(_render(username, success="Host saved."))

View File

@@ -82,12 +82,12 @@ Orchestrated sessions persist to history exactly like regular chat.
### Available Tools
40 tools across 11 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call.
45 tools across 12 categories. Each tool schema is sent to the model on every orchestrated call — fewer active tools means fewer tokens per call.
| Category | Tools |
|---|---|
| **Web** | `web_search`, `http_fetch` |
| **Files** | `file_read`, `file_list`, `file_write` |
| **Files** | `file_read`, `file_list`, `file_write`, `session_search` |
| **Shell** | `shell_exec`, `claude_allow_dir` |
| **System** | `cortex_restart`, `cortex_logs`, `cortex_status`, `cortex_update` |
| **Tasks** | `task_list`, `task_create`, `task_update`, `task_complete` |
@@ -97,8 +97,9 @@ Orchestrated sessions persist to history exactly like regular chat.
| **Notifications** | `web_push`, `email_send`, `nc_talk_send` |
| **Aether Journals** | `ae_journal_list/search`, `ae_journal_entries_list`, `ae_journal_entry_read/create/update/disable/append/prepend` |
| **Agent Notes** | `agent_notes_read`, `agent_notes_write`, `agent_notes_append`, `agent_notes_clear` |
| **Agents** | `spawn_agent` |
File, Shell, System, and some Notification tools are **admin-only** and not visible to regular users.
File, Shell, System, Agents, and some Notification tools are **admin-only** and not visible to regular users.
### Per-Role Tool Sets
@@ -277,6 +278,8 @@ Leave all slots empty to use the server default.
**Per-role tool sets:** Expand any role card to configure which tool categories the orchestrator can use when that role is active. Unchecked categories are hidden from the model entirely — reducing token overhead on every orchestrated call. Leaving all categories unchecked means all tools the user has access to are available (the default).
**Inject timestamp:** Each role card has an "Inject current date & time into system prompt" checkbox (default on). Disable it for pure processing roles (summarizer, classifier, translator) that don't need clock awareness.
---
## Nextcloud Talk Bot

View File

@@ -1,6 +1,6 @@
# Tool Reference
> This reference covers all 44 orchestrator tools available when the ⚡ toggle is on.
> This reference covers all 45 orchestrator tools available when the ⚡ toggle is on.
> Tools are invoked automatically by the orchestrator — you don't call them directly.
¹ **Admin only** — requires the `admin` role. Invisible to regular users.
@@ -113,3 +113,11 @@ Private, durable notes visible only to the orchestrator — not surfaced to user
| `agent_notes_write` | Overwrite the notes file completely |
| `agent_notes_append` | Append a timestamped entry (keeps last 3 backups automatically) |
| `agent_notes_clear` | Erase all notes (backs up first) |
## Agents ¹
Spawn sub-agents that run their own tool loop using a specific role's model and tools.
| Tool | What it does |
|---|---|
| `spawn_agent` ¹ | Spawn a sub-agent synchronously — blocks until the task completes or times out. Params: `task`, `role` (default `chat`), `tier` (14, default 1), `timeout` seconds, `max_rounds` override. Only works with `local_openai` and `gemini_api` models. |

View File

@@ -403,6 +403,10 @@
<option value="openai">OpenAI-compatible (OpenRouter, etc.)</option>
</select>
</div>
<div class="field" style="flex:0 0 auto; width:6rem">
<label>Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20" value="3" style="width:100%">
</div>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-primary btn-sm">Add Host</button>

View File

@@ -70,6 +70,7 @@ from tools.agent_notes import (
agent_notes_append as _agent_notes_append,
agent_notes_clear as _agent_notes_clear,
)
from tools.agents import spawn_agent as _spawn_agent
# ── Declaration imports ───────────────────────────────────────────────────────
@@ -84,6 +85,7 @@ import tools.reminders as _mod_reminders
import tools.scratch as _mod_scratch
import tools.notify as _mod_notify
import tools.agent_notes as _mod_agent_notes
import tools.agents as _mod_agents
# ── Tool categories — used by the Model Registry UI for grouped checkboxes ───
@@ -106,6 +108,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
],
"Aether Tasks": ["ae_task_list"],
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
"Agents": ["spawn_agent"],
}
# ── Callable registry ─────────────────────────────────────────────────────────
@@ -156,6 +159,7 @@ _CALLABLES: dict[str, callable] = {
"agent_notes_write": _agent_notes_write,
"agent_notes_append": _agent_notes_append,
"agent_notes_clear": _agent_notes_clear,
"spawn_agent": _spawn_agent,
}
# ── Role-based access control ─────────────────────────────────────────────────
@@ -172,6 +176,7 @@ TOOL_ROLES: dict[str, str] = {
"file_list": "admin",
"file_write": "admin",
"ae_task_list": "admin",
"spawn_agent": "admin",
"email_send": "admin",
"nc_talk_send": "admin",
}
@@ -208,6 +213,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
+ _mod_ae_knowledge.DECLARATIONS
+ _mod_ae_tasks.DECLARATIONS
+ _mod_agent_notes.DECLARATIONS
+ _mod_agents.DECLARATIONS
)
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)

204
cortex/tools/agents.py Normal file
View File

@@ -0,0 +1,204 @@
"""
Agent spawning tool — lets the orchestrator launch sub-agents synchronously.
Sub-agents run using the model assigned to the specified role. The call blocks
until the sub-agent completes or times out.
Supported model types: local_openai, gemini_api.
claude_cli / gemini_cli are chat-only and do not support sub-agent tool loops.
"""
import asyncio
import logging
from google.genai import types
logger = logging.getLogger(__name__)
# Per-host semaphores — keyed by "host:<host_id>" or "type:<model_type>"
# Created lazily on first use; never deleted (module-level singletons)
_semaphores: dict[str, asyncio.Semaphore] = {}
_sem_lock = asyncio.Lock()
async def _get_semaphore(key: str, max_concurrent: int) -> asyncio.Semaphore:
"""Return (or create) the semaphore for a given host/type key."""
async with _sem_lock:
if key not in _semaphores:
_semaphores[key] = asyncio.Semaphore(max_concurrent)
return _semaphores[key]
async def spawn_agent(
task: str,
role: str = "chat",
tier: int = 1,
timeout: int = 120,
max_rounds: int | None = None,
) -> str:
"""
Spawn a sub-agent to complete a task synchronously.
The sub-agent uses the model and tools assigned to the given role. Returns
the sub-agent's response as a string.
"""
import model_registry
from context_loader import load_context
from auth_utils import get_user_role, get_tool_policy
from persona import get_user
user = get_user() or "scott"
role_cfg = model_registry.get_role_config(user, role)
model_cfg = model_registry.get_model_for_role(user, role)
if not model_cfg:
return f"spawn_agent: no model configured for role '{role}'"
model_type = model_cfg.get("type", "unknown")
if model_type not in ("local_openai", "gemini_api"):
return (
f"spawn_agent: model type '{model_type}' does not support tool-enabled sub-agents. "
f"Assign a local_openai or gemini_api model to role '{role}'."
)
# Determine concurrency key and semaphore limit
host_id = model_cfg.get("host_id")
if host_id:
registry = model_registry.get_registry(user)
host = next((h for h in registry.get("hosts", []) if h["id"] == host_id), None)
max_concurrent = (host or {}).get("max_concurrent", 3)
sem_key = f"host:{host_id}"
else:
max_concurrent = 5 if model_type == "gemini_api" else 3
sem_key = f"type:{model_type}"
sem = await _get_semaphore(sem_key, max_concurrent)
system_prompt = load_context(
tier=tier,
include_long=(tier >= 2),
include_mid=(tier >= 2),
include_short=(tier >= 2),
role_append=role_cfg.get("system_append", ""),
inject_datetime=role_cfg.get("inject_datetime", True),
)
user_role = get_user_role(user)
tool_list = role_cfg.get("tools")
policy = get_tool_policy(user)
confirm_allow = set(policy.get("allow", []))
confirm_deny = set(policy.get("deny", []))
if max_rounds is not None:
model_cfg = dict(model_cfg)
model_cfg["max_rounds"] = max_rounds
async def _run() -> str:
if model_type == "local_openai":
import openai_orchestrator
result = await openai_orchestrator.run(
task=task,
system_prompt=system_prompt,
model_cfg=model_cfg,
respond_with_final=True,
user_role=user_role,
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
)
if result.checkpoint:
return (
"Sub-agent requires user confirmation — "
"confirmation gates are not supported inside spawn_agent. "
"Pre-allow the tool in the user's tool policy or use a different role."
)
return result.response or "(sub-agent returned no output)"
# gemini_api
import orchestrator_engine
from auth_utils import get_user_gemini_key
gemini_key = model_cfg.get("api_key") or get_user_gemini_key(user)
result = await orchestrator_engine.run(
task=task,
system_prompt=system_prompt,
session_messages=None,
respond_with_claude=True,
gemini_api_key=gemini_key,
model_name=model_cfg.get("model_name"),
response_role=role,
user_role=user_role,
tool_list=tool_list,
confirm_allow=confirm_allow,
confirm_deny=confirm_deny,
)
if result.checkpoint:
return (
"Sub-agent requires user confirmation — "
"confirmation gates are not supported inside spawn_agent."
)
return result.response or "(sub-agent returned no output)"
async with sem:
try:
logger.info(
"spawn_agent: role=%s tier=%d timeout=%ds task=%.80s",
role, tier, timeout, task,
)
response = await asyncio.wait_for(_run(), timeout=float(timeout))
logger.info("spawn_agent: done role=%s response=%d chars", role, len(response))
return response
except asyncio.TimeoutError:
logger.warning("spawn_agent: timed out after %ds role=%s", timeout, role)
return f"Sub-agent timed out after {timeout}s (role={role})"
except Exception as e:
logger.exception("spawn_agent: failed role=%s", role)
return f"Sub-agent error ({role}): {e}"
DECLARATIONS = [
types.FunctionDeclaration(
name="spawn_agent",
description=(
"Spawn a sub-agent to complete a task synchronously. "
"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.)."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"task": types.Schema(
type=types.Type.STRING,
description="The complete task description for the sub-agent.",
),
"role": types.Schema(
type=types.Type.STRING,
description=(
"Role determining the model and tools. "
"E.g. 'research' for web lookups, 'coder' for code tasks, "
"'distill' for summarization. Defaults to 'chat'."
),
),
"tier": types.Schema(
type=types.Type.INTEGER,
description=(
"Context tier: 1 = minimal (fast, identity only), "
"2 = standard (+ memory), 3 = + last 2 session logs. "
"Use 1 for pure processing tasks."
),
),
"timeout": types.Schema(
type=types.Type.INTEGER,
description="Max seconds to wait (default 120).",
),
"max_rounds": types.Schema(
type=types.Type.INTEGER,
description="Override max tool-loop iterations for this call.",
),
},
required=["task"],
),
)
]