yetuge 7eb41cfc93
refactor(gateway): hoist the persistent-write drain into one shared helper (#6151)
* refactor(gateway): hoist the persistent-write drain into one shared helper

Each persistent-write surface forked its own per-router drain wrapper:
_drained_write in routers/agents.py (action label, expected_errors
pass-through, lost-failure type-only logging), _run_store_mutation in
routers/subagents.py (bare drain), and the inline await_drained call in
routers/managed_models.py. Hoist the superset into
app.gateway.persistent_writes.run_drained_write and point all three
routers at it, per the review suggestion on #6087 (its family
predecessors #6078/#6092/#6093 have since landed).

The subagents and managed-models call sites now also emit the type-only
lost-failure log line on a drained-then-failed write (previously
silent), matching the tradeoff acknowledged in the #6087 review.
artifacts.py and personal_mcp.py keep their direct await_drained forms:
the former drains a coroutine with its own rollback flow, the latter
needs no label or expected-error handling.

Router tests move their monkeypatch targets to the shared name; the
lost-failure assertion pins the helper's logger. AGENTS.md points the
next persistent-write surface at the canonical implementation.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(gateway): quiet expected domain errors and scope the AGENTS.md drain claim

Review follow-ups on #6151:

- The subagents create/update call sites pass (ManagedSubagentExistsError,)
  and (FileNotFoundError,) so the shared helper stays silent on routine
  4xx outcomes (duplicate-name 409, stale-store 404 race), matching
  create_agent's (AgentExistsError,).
- The managed-models save passes (HTTPException,), so the helper stops
  double-logging failures that _save_with_failure_logging already
  reports and stays silent on its routine 4xx outcomes.
- The AGENTS.md sentence now names the three converted routers instead
  of implying every persistent-write surface uses the helper; the
  shortened form also keeps the file under the guidance byte budget
  that agent-guidance enforces.

Signed-off-by: yetuge <2219677952@qq.com>

---------

Signed-off-by: yetuge <2219677952@qq.com>
2026-10-01 20:13:57 +08:00

240 lines
9.9 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 app.gateway.persistent_writes import run_drained_write
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 run_drained_write("Create managed subagent", store.create, (ManagedSubagentExistsError,), 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 run_drained_write("Update managed subagent", store.update, (FileNotFoundError,), 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 run_drained_write("Delete managed subagent", store.delete, (), name):
raise HTTPException(status_code=404, detail=f"Managed subagent '{name}' not found")