mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
* 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>
239 lines
9.7 KiB
Python
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")
|