Aari 1aa813ddb3
feat: add managed subagents and delegation scopes (#4887)
* feat: manage and scope subagents

* fix: address subagent review feedback

* fix: address managed subagent review feedback

* fix: harden subagent settings semantics

* fix: harden managed subagent cache invalidation

* fix: reuse assembled lead agent inputs

* fix: migrate managed subagent definitions

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-08-24 11:04:23 +08:00

239 lines
9.7 KiB
Python

"""Catalog and administrator CRUD API for subagent definitions."""
from __future__ import annotations
import asyncio
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from app.gateway.deps import is_admin_user, require_admin_user
from deerflow.config.app_config import get_app_config
from deerflow.persistence.managed_subagents import (
ManagedSubagentDefinition,
ManagedSubagentExistsError,
get_managed_subagent_store,
)
from deerflow.persistence.managed_subagents.base import (
MANAGED_SUBAGENT_NAME_PATTERN,
normalize_managed_subagent_name,
)
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
router = APIRouter(prefix="/api/subagents", tags=["subagents"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges are required to manage subagents."
class SubagentResponse(BaseModel):
name: str
display_name: str | None = None
description: str
system_prompt: str | None = None
tools: list[str] | None = None
disallowed_tools: list[str] | None = None
skills: list[str] | None = None
model: str = "inherit"
max_turns: int = 50
timeout_seconds: int = 900
enabled: bool = True
source: Literal["builtin", "config", "managed"]
editable: bool = False
conflict: bool = False
config_overrides: dict[str, Any] = Field(default_factory=dict)
class SubagentsListResponse(BaseModel):
subagents: list[SubagentResponse]
class ManagedSubagentCreateRequest(BaseModel):
name: str = Field(pattern=MANAGED_SUBAGENT_NAME_PATTERN.pattern)
display_name: str | None = None
description: str = Field(min_length=1)
system_prompt: str = Field(min_length=1)
tools: list[str] | None = None
disallowed_tools: list[str] | None = None
skills: list[str] | None = None
model: str = "inherit"
max_turns: int = Field(default=50, ge=1)
timeout_seconds: int = Field(default=900, ge=1)
enabled: bool = True
class ManagedSubagentUpdateRequest(BaseModel):
display_name: str | None = None
description: str | None = Field(default=None, min_length=1)
system_prompt: str | None = Field(default=None, min_length=1)
tools: list[str] | None = None
disallowed_tools: list[str] | None = None
skills: list[str] | None = None
model: str | None = None
max_turns: int | None = Field(default=None, ge=1)
timeout_seconds: int | None = Field(default=None, ge=1)
enabled: bool | None = None
def _validate_model(model: str, app_config) -> None:
if model == "inherit":
return
if app_config.get_model_config(model) is None:
raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use 'inherit' or a configured model name.")
def _validate_path_name(name: str) -> str:
try:
return normalize_managed_subagent_name(name)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
def _explicit_overrides(name: str, app_config) -> dict[str, Any]:
override = app_config.subagents.agents.get(name)
if override is None:
return {}
result: dict[str, Any] = {}
for field in ("timeout_seconds", "max_turns", "model", "skills"):
if field not in override.model_fields_set:
continue
value = getattr(override, field)
if value is not None:
result[field] = value
return result
def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
app_config = get_app_config()
config_names = set(app_config.subagents.custom_agents)
reserved_names = set(BUILTIN_SUBAGENTS) | config_names
items: list[SubagentResponse] = []
for name, definition in BUILTIN_SUBAGENTS.items():
items.append(
SubagentResponse(
name=name,
description=definition.description,
system_prompt=definition.system_prompt if include_system_prompt else None,
tools=definition.tools,
disallowed_tools=definition.disallowed_tools,
skills=definition.skills,
model=definition.model,
max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds,
source="builtin",
config_overrides=_explicit_overrides(name, app_config),
)
)
for name, definition in app_config.subagents.custom_agents.items():
items.append(
SubagentResponse(
name=name,
description=definition.description,
system_prompt=definition.system_prompt if include_system_prompt else None,
tools=definition.tools,
disallowed_tools=definition.disallowed_tools,
skills=definition.skills,
model=definition.model,
max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds,
source="config",
conflict=name in BUILTIN_SUBAGENTS,
config_overrides=_explicit_overrides(name, app_config),
)
)
store = get_managed_subagent_store(app_config)
for definition in store.list():
items.append(
SubagentResponse(
**definition.model_dump(exclude={"system_prompt"}),
system_prompt=definition.system_prompt if include_system_prompt else None,
source="managed",
editable=True,
conflict=definition.name in reserved_names,
config_overrides=_explicit_overrides(definition.name, app_config),
)
)
source_order = {"builtin": 0, "config": 1, "managed": 2}
items.sort(key=lambda item: (item.name, source_order[item.source]))
return SubagentsListResponse(subagents=items)
@router.get("", response_model=SubagentsListResponse, summary="List Subagents")
async def list_subagents(request: Request) -> SubagentsListResponse:
"""List the runtime catalog; prompts remain visible only to admins."""
include_system_prompt = await is_admin_user(request)
return await asyncio.to_thread(_catalog, include_system_prompt)
@router.post("", response_model=SubagentResponse, status_code=201, summary="Create Managed Subagent")
async def create_managed_subagent(request: Request, body: ManagedSubagentCreateRequest) -> SubagentResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
try:
definition = ManagedSubagentDefinition(**body.model_dump(exclude_none=True))
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
app_config = await asyncio.to_thread(get_app_config)
_validate_model(definition.model, app_config)
if definition.name in BUILTIN_SUBAGENTS or definition.name in app_config.subagents.custom_agents:
raise HTTPException(status_code=409, detail=f"Subagent name '{definition.name}' is reserved by a built-in or config.yaml definition.")
store = get_managed_subagent_store(app_config)
try:
await asyncio.to_thread(store.create, definition)
except ManagedSubagentExistsError:
raise HTTPException(status_code=409, detail=f"Managed subagent '{definition.name}' already exists")
return SubagentResponse(
**definition.model_dump(exclude={"system_prompt"}),
system_prompt=definition.system_prompt,
source="managed",
editable=True,
config_overrides=_explicit_overrides(definition.name, app_config),
)
@router.put("/{name}", response_model=SubagentResponse, summary="Update Managed Subagent")
async def update_managed_subagent(name: str, request: Request, body: ManagedSubagentUpdateRequest) -> SubagentResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
name = _validate_path_name(name)
app_config = await asyncio.to_thread(get_app_config)
store = get_managed_subagent_store(app_config)
try:
existing = await asyncio.to_thread(store.get, name)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Managed subagent '{name}' not found")
changes = body.model_dump(exclude_unset=True)
updated = existing.model_copy(update=changes)
# model_copy does not re-run validation, so round-trip through the model.
try:
updated = ManagedSubagentDefinition.model_validate(updated.model_dump())
except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc
_validate_model(updated.model, app_config)
try:
await asyncio.to_thread(store.update, updated)
except FileNotFoundError:
# The definition may be deleted by another administrator after the
# read above. Preserve the endpoint's not-found contract instead of
# leaking that race as a 500.
raise HTTPException(status_code=404, detail=f"Managed subagent '{name}' not found")
conflict = updated.name in BUILTIN_SUBAGENTS or updated.name in app_config.subagents.custom_agents
return SubagentResponse(
**updated.model_dump(exclude={"system_prompt"}),
system_prompt=updated.system_prompt,
source="managed",
editable=True,
conflict=conflict,
config_overrides=_explicit_overrides(updated.name, app_config),
)
@router.delete("/{name}", status_code=204, summary="Delete Managed Subagent")
async def delete_managed_subagent(name: str, request: Request) -> None:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
name = _validate_path_name(name)
app_config = await asyncio.to_thread(get_app_config)
store = get_managed_subagent_store(app_config)
if not await asyncio.to_thread(store.delete, name):
raise HTTPException(status_code=404, detail=f"Managed subagent '{name}' not found")