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>
This commit is contained in:
Aari 2026-08-24 11:04:23 +08:00 committed by GitHub
parent 9232e1e6a9
commit 1aa813ddb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 2911 additions and 112 deletions

View File

@ -1119,6 +1119,8 @@ Sub-agents are an optimization, not the default response to a complex request.
The lead agent can spawn sub-agents on the fly — each with its own scoped context, tools, and termination conditions — when delegation has clear net benefit from real parallel latency, specialist capability, or context isolation. It keeps interdependent scopes and overlapping side effects out of parallel dispatch; a bounded sequential chain can still run in one sub-agent when specialist or context-isolation benefit clearly wins. The lead uses the fewest useful sub-agents and re-evaluates later batches instead of fanning out solely because a task is large or multi-step. Sub-agents report back structured results, and the lead agent verifies and synthesizes them into a coherent output. Deterministic tool receipts cover both direct tool messages and state-updating `Command` results such as delegated `task` responses; when the receipt ledger reaches its context budget, it retains the newest actions and their original receipt IDs. Operators can disable this provenance layer with `verification.receipts_enabled: false`. Their configured skills are resolved from the same user-scoped catalog as the lead agent, so user-owned custom skills remain available without exposing another user's version. Their internal AI and tool messages stay scoped to the delegated graph instead of entering the parent chat stream. Reloaded thread history enforces the same boundary: callback-captured sub-agent AI responses remain available in run-event diagnostics but are excluded from the parent transcript, while the parent `task` result remains attached to its subtask card. Long-running sub-agents compact older history when summarization is enabled and re-inject the summary as guarded, hidden durable context before continuing, so recent assistant/tool activity remains grounded in the task. Provider/model request failures are reported as failed sub-agent tasks rather than successful results, so the lead agent and Web UI can react to them correctly. Concurrent parent runs also receive independent server-side sub-agent execution IDs, so a provider that reuses a tool-call ID cannot make one run poll, cancel, or clean up another run's background task. Collapsed sub-agent cards show the effective model and, when the provider returns usage metadata, a cumulative token total that updates after each completed sub-agent LLM call and persists after a reload. When token usage tracking is enabled, completed sub-agent usage is attributed back to the dispatching step from that run's terminal tool-message metadata rather than a process-global provider-ID cache.
Administrators can add, edit, disable, and delete reusable worker definitions from **Settings → Subagents**. Built-in and `config.yaml` definitions remain visible there as read-only entries. The default Lead Agent can use every enabled runtime sub-agent; each page-created Custom Agent can instead allow all, none, or a selected set. That selection is enforced both in the model-visible directory and by the server-side `task` tool. Managed definitions are deployment-wide in this version and follow `agent_storage.backend`: atomic files for a local deployment or the shared application database for multiple instances.
For example, independent read-only research can run concurrently when the wall-clock savings outweigh duplicated discovery and synthesis cost, while a repository refactor with shared files and sequential test feedback remains with the lead agent. When `max_concurrent_subagents` is `1`, parallel and multi-batch routing guidance is disabled; delegation remains available only for material specialist or context-isolation benefit.
### Sandbox & File System

View File

@ -644,6 +644,8 @@ Sub-agent 是一种执行优化,而不是遇到复杂任务时的默认选择
lead agent 只会在委派具有明确净收益时动态拉起 sub-agents例如真正缩短耗时的并行工作、专业能力收益或上下文隔离收益。存在跨 Agent 依赖或重叠副作用的工作不会并行分派;当专业能力或上下文隔离收益明显占优时,一条有界的顺序任务链仍可交给一个 sub-agent 完成。lead agent 会使用能取得收益的最少 sub-agents并在每一批完成后重新评估而不会仅仅因为任务规模大或步骤多就继续拆分。每个 sub-agent 都有自己独立的上下文、工具和终止条件,返回结构化结果后由 lead agent 验证并汇总成完整输出。
管理员可以在**设置 → 子智能体**中添加、修改、停用和删除可复用的工作智能体;内置项和 `config.yaml` 项会在同一目录中以只读方式展示。默认 Lead Agent 可以使用全部已启用的运行时 sub-agents页面创建的每个 Custom Agent 则可以选择允许全部、全部禁用或仅允许指定项。该范围同时约束模型可见目录和服务端 `task` 工具,不能通过直接填写名称绕过。当前版本的设置页管理定义是部署级全局数据,并跟随 `agent_storage.backend`:单机使用原子文件,多实例使用共享应用数据库。
例如,彼此独立的只读研究可以在并行节省的时间明显高于重复检索和结果合并成本时并发执行;而会修改相同文件、依赖连续测试反馈的仓库重构则由 lead agent 直接完成。当 `max_concurrent_subagents``1` 时,提示词会关闭并行和多批次路由指导,仅在专业能力或上下文隔离具有明确收益时保留委派。
### Sandbox 与文件系统

View File

@ -49,6 +49,7 @@ reads/searches.
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache |
| **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration |
| **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes |
| **Subagents** (`/api/subagents`) | Admin managed-worker CRUD and listing. |
| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |

View File

@ -34,6 +34,7 @@ from app.gateway.routers import (
runs,
scheduled_tasks,
skills,
subagents,
suggestions,
thread_runs,
threads,
@ -774,6 +775,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Agents API is mounted at /api/agents
app.include_router(agents.router)
# Deployment-level subagent catalog and admin management.
app.include_router(subagents.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)

View File

@ -132,9 +132,10 @@ def _validate_agent_storage(config: AppConfig) -> None:
"""Fail fast on an agent-storage backend the database cannot support.
``agent_storage.backend: db`` needs a durable, shared SQL database a
``memory`` database is per-process, so agent definitions would silently
diverge across nodes (and there is no SQL URL to open). Mirrors deermem's
create_storage fail-fast and the multi-worker gate above.
``memory`` database is per-process, so custom-agent and managed-subagent
definitions would silently diverge across nodes (and there is no SQL URL
to open). Mirrors deermem's create_storage fail-fast and the multi-worker
gate above.
Also warns when a multi-worker Postgres deployment leaves agent storage on
``file``: custom agents created on one node's local disk are invisible to
@ -155,7 +156,9 @@ def _validate_agent_storage(config: AppConfig) -> None:
workers = 1
if workers > 1 and db_backend == "postgres" and backend == "file":
logger.warning(
"GATEWAY_WORKERS=%s with database.backend='postgres' but agent_storage.backend='file': custom agents are stored per-node on local disk and are not visible across workers/nodes. Set agent_storage.backend='db' to share them.",
"GATEWAY_WORKERS=%s with database.backend='postgres' but agent_storage.backend='file': "
"custom agents and managed subagents are stored per-node on local disk and are not visible "
"across workers/nodes. Set agent_storage.backend='db' to share them.",
workers,
)
@ -783,13 +786,13 @@ async def get_current_user_from_request(request: Request):
return user
async def require_admin_user(request: Request, *, detail: str) -> None:
"""Require the authenticated caller to be an admin user.
async def is_admin_user(request: Request) -> bool:
"""Return whether the authenticated caller is an admin user.
``AuthMiddleware`` normally stamps ``request.state.user`` before the request
reaches a router. Falling back to the strict dependency keeps the route safe
in tests or alternative ASGI compositions that mount a router without the
global middleware. ``detail`` is the route-specific 403 message.
global middleware.
Centralising this here means a future change to the admin definition (e.g.
allowing an internal system role, adding audit logging, or switching to a
@ -801,7 +804,17 @@ async def require_admin_user(request: Request, *, detail: str) -> None:
if user is None:
user = await get_current_user_from_request(request)
if getattr(user, "system_role", None) != "admin":
return getattr(user, "system_role", None) == "admin"
async def require_admin_user(request: Request, *, detail: str) -> None:
"""Require the authenticated caller to be an admin user.
``detail`` is the route-specific 403 message. The shared predicate keeps
read-side redaction and write authorization on the same admin definition.
"""
if not await is_admin_user(request):
raise HTTPException(status_code=403, detail=detail)

View File

@ -43,6 +43,7 @@ class AgentResponse(BaseModel):
model: str | None = Field(default=None, description="Optional model override")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Subagent allowlist (None=all enabled, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
thinking_enabled: bool | None = Field(default=None, description="Per-agent thinking-mode default (None = runtime default)")
reasoning_effort: ReasoningEffort | None = Field(default=None, description="Per-agent reasoning-effort default (None = runtime default)")
@ -63,6 +64,7 @@ class AgentCreateRequest(BaseModel):
model: str | None = Field(default=None, description="Optional model override")
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Subagent allowlist (None=all enabled, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
thinking_enabled: bool | None = Field(default=None, description="Per-agent thinking-mode default (None = runtime default)")
reasoning_effort: ReasoningEffort | None = Field(default=None, description="Per-agent reasoning-effort default (None = runtime default)")
@ -76,6 +78,7 @@ class AgentUpdateRequest(BaseModel):
model: str | None = Field(default=None, description="Updated model override")
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
allowed_subagents: list[str] | None = Field(default=None, description="Updated subagent allowlist (None=all, []=none)")
model_settings: AgentModelSettings | None = Field(default=None, description="Updated per-agent sampling overrides")
thinking_enabled: bool | None = Field(default=None, description="Updated per-agent thinking-mode default")
reasoning_effort: ReasoningEffort | None = Field(default=None, description="Updated per-agent reasoning-effort default")
@ -189,6 +192,7 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
model=agent_cfg.model,
tool_groups=agent_cfg.tool_groups,
skills=agent_cfg.skills,
allowed_subagents=agent_cfg.allowed_subagents,
model_settings=agent_cfg.model_settings,
thinking_enabled=agent_cfg.thinking_enabled,
reasoning_effort=agent_cfg.reasoning_effort,
@ -325,6 +329,8 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
config_data["tool_groups"] = request.tool_groups
if request.skills is not None:
config_data["skills"] = request.skills
if request.allowed_subagents is not None:
config_data["allowed_subagents"] = request.allowed_subagents
# model / model_settings / thinking_enabled / reasoning_effort (issue #4336).
_apply_model_behavior(config_data, request)
@ -405,7 +411,7 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
# This is critical for skills where None means "inherit all" (not "don't change").
fields_set = request.model_fields_set
config_changed = bool(fields_set & ({"description", "tool_groups", "skills"} | set(_MODEL_BEHAVIOR_FIELDS)))
config_changed = bool(fields_set & ({"description", "tool_groups", "skills", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))
updated: dict | None = None
if config_changed:
@ -426,6 +432,11 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
if new_skills is not None:
updated["skills"] = new_skills
# allowed_subagents: None = all, [] = hard deny, list = whitelist.
new_allowed_subagents = request.allowed_subagents if "allowed_subagents" in fields_set else agent_cfg.allowed_subagents
if new_allowed_subagents is not None:
updated["allowed_subagents"] = new_allowed_subagents
# model / model_settings / thinking_enabled / reasoning_effort:
# take explicitly-set request fields, else preserve the existing
# value (issue #4336).

View File

@ -0,0 +1,238 @@
"""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")

View File

@ -16,6 +16,7 @@
- Dynamic model selection via `create_chat_model()` with thinking/vision support
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
- Each assembly renders the system prompt and composes middleware exactly once; the same prompt and middleware objects must be passed to both `create_agent()` and the assembly descriptor so extension observations match the running graph, including Custom Agent `allowed_subagents` scope.
**ThreadState** (`packages/harness/deerflow/agents/thread_state.py`):
- Extends `AgentState` with: `sandbox`, `thread_data`, `title`, `artifacts`, `todos`, `uploaded_files`, `viewed_images`, `goal`, `promoted`, `delegations`, `skill_context`, `summary_text`

View File

@ -869,7 +869,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False)
subagent_enabled = cfg.get("subagent_enabled", False)
requested_subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
max_total_subagents = cfg.get("max_total_subagents", _default_max_total_subagents(resolved_app_config))
is_bootstrap = cfg.get("is_bootstrap", False)
@ -877,6 +877,15 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
agent_name = validate_agent_name(cfg.get("agent_name"))
agent_config = load_agent_config(agent_name, user_id=resolved_user_id) if not is_bootstrap else None
# Keep compatibility with lightweight AgentConfig-shaped objects used by
# integrations that predate caller-level subagent restrictions.
allowed_subagents = getattr(agent_config, "allowed_subagents", None) if agent_config is not None else None
# The request switch may disable delegation, but it can never widen the
# server-side custom-agent policy. An explicit empty list is a hard deny.
subagent_enabled = bool(requested_subagent_enabled and allowed_subagents != [])
config.setdefault("configurable", {})["subagent_enabled"] = subagent_enabled
if isinstance(config.get("context"), dict):
config["context"]["subagent_enabled"] = subagent_enabled
available_skills = _available_skill_names(agent_config, is_bootstrap)
# Custom agent model from agent config (if any), or None to let _resolve_model_name pick the default
agent_model_name = agent_config.model if agent_config and agent_config.model else None
@ -935,6 +944,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
"subagent_enabled": subagent_enabled,
"tool_groups": agent_config.tool_groups if agent_config else None,
"available_skills": sorted(available_skills) if available_skills is not None else None,
"allowed_subagents": list(allowed_subagents) if allowed_subagents is not None else None,
}
)
@ -1014,6 +1024,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
deferred_names=setup.deferred_names,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
@ -1129,6 +1140,7 @@ def _assemble_lead_agent(config: RunnableConfig, *, app_config: AppConfig) -> Le
mcp_routing_hints_section=mcp_routing_hints_section,
user_id=resolved_user_id,
skill_names=skill_setup.skill_names or None,
allowed_subagents=allowed_subagents,
)
graph = create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False, model_overrides=agent_model_overrides),

View File

@ -343,6 +343,7 @@ def _build_subagent_section(
max_total: int = DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN,
*,
app_config: AppConfig | None = None,
allowed_subagents: list[str] | None = None,
) -> str:
"""Build the subagent system prompt section with dynamic subagent limits.
@ -355,7 +356,12 @@ def _build_subagent_section(
"""
n = clamp_subagent_concurrency(max_concurrent)
total = clamp_total_subagents_per_run(max_total)
available_names = get_available_subagent_names(app_config=app_config) if app_config is not None else get_available_subagent_names()
if allowed_subagents is None:
available_names = get_available_subagent_names(app_config=app_config) if app_config is not None else get_available_subagent_names()
else:
available_names = get_available_subagent_names(app_config=app_config, allowed_subagents=allowed_subagents) if app_config is not None else get_available_subagent_names(allowed_subagents=allowed_subagents)
if not available_names:
return ""
bash_available = "bash" in available_names
# Dynamically build subagent type descriptions from registry (aligned with Codex's
@ -1003,6 +1009,7 @@ def apply_prompt_template(
mcp_routing_hints_section: str = "",
user_id: str | None = None,
skill_names: frozenset[str] | None = None,
allowed_subagents: list[str] | None = None,
) -> str:
# Include subagent section only if enabled (from runtime parameter)
n = clamp_subagent_concurrency(max_concurrent_subagents)
@ -1011,7 +1018,7 @@ def apply_prompt_template(
subagents_config = getattr(app_config, "subagents", None) if app_config is not None else None
total = getattr(subagents_config, "max_total_per_run", DEFAULT_MAX_TOTAL_SUBAGENTS_PER_RUN)
total = clamp_total_subagents_per_run(total)
subagent_section = _build_subagent_section(n, total, app_config=app_config) if subagent_enabled else ""
subagent_section = _build_subagent_section(n, total, app_config=app_config, allowed_subagents=allowed_subagents) if subagent_enabled else ""
# Add subagent reminder to critical_reminders if enabled
reminder_benefits = "specialist capability or context isolation" if n == 1 else "real parallel latency, specialist capability, or context isolation"

View File

@ -1,17 +1,18 @@
"""Custom-agent definition storage configuration.
"""Custom-agent and managed-subagent definition storage configuration.
Controls where custom agent *definitions* (``config.yaml`` + ``SOUL.md``) are
persisted. This is orthogonal to :class:`DatabaseConfig` (which governs the
persisted, together with deployment-level managed subagent definitions. This
is orthogonal to :class:`DatabaseConfig` (which governs the
run/thread/event persistence layer) and to the deermem memory store.
Backends:
- file: Per-user files under ``{base_dir}/users/{user_id}/agents/{name}/``
(today's layout, unchanged). Single-node by construction — an agent created
on one node is invisible to other nodes without a shared mount. This is the
default so single-node and zero-config development are unaffected.
- db: A row in the ``agents`` table of the existing SQL persistence layer,
shared by every node. Requires ``database.backend`` to be ``sqlite`` or
``postgres`` (validated at startup; see the gateway ``deps`` module).
for Custom Agents and one JSON file per managed subagent under
``{base_dir}/managed-subagents/``. Node-local without a shared mount. This
remains the default so zero-config development is unaffected.
- db: Rows in the ``agents`` and ``managed_subagents`` tables of the existing
SQL persistence layer, shared by every node. Requires ``database.backend``
to be ``sqlite`` or ``postgres`` (validated at startup).
Agent *memory* (``memory.json``) is a separate concern handled by the deermem
storage layer and is not affected by this switch.
@ -28,10 +29,10 @@ class AgentStorageConfig(BaseModel):
backend: Literal["file", "db"] = Field(
default="file",
description=(
"Storage backend for custom agent definitions (config.yaml + SOUL.md). "
"'file' (default) keeps today's per-user on-disk layout — single-node only. "
"'db' stores each agent as a row in the shared SQL persistence layer so a "
"multi-instance deployment sees the same agents on every node; it requires "
"Storage backend for custom-agent and managed-subagent definitions. "
"'file' (default) keeps their on-disk layouts and is node-local without a shared mount. "
"'db' stores both definition types in the shared SQL persistence layer so a "
"multi-instance deployment sees the same catalog on every node; it requires "
"database.backend to be 'sqlite' or 'postgres'."
),
)

View File

@ -201,6 +201,11 @@ class AgentConfig(BaseModel):
# - [] (explicit empty list): disable all skills
# - ["skill1", "skill2"]: load only the specified skills
skills: list[str] | None = None
# Controls which deployment-level subagents this custom agent may invoke:
# None = all currently enabled definitions, [] = none, list = allowlist.
# The default Lead Agent has no AgentConfig and therefore keeps access to
# the full catalog.
allowed_subagents: list[str] | None = None
# Per-agent LLM sampling overrides (temperature / max_tokens) layered on top
# of the referenced model profile. None = no overrides (issue #4336).
model_settings: AgentModelSettings | None = None
@ -232,6 +237,7 @@ MANAGED_AGENT_CONFIG_FIELDS: frozenset[str] = frozenset(
"model",
"tool_groups",
"skills",
"allowed_subagents",
"model_settings",
"thinking_enabled",
"reasoning_effort",

View File

@ -283,7 +283,7 @@ class AppConfig(BaseModel):
default_factory=AgentStorageConfig,
description=format_field_description(
"agent_storage",
field_doc="Custom agent definition storage backend ('file' for today's per-user on-disk layout, 'db' to share definitions across nodes via the SQL persistence layer).",
field_doc="Custom-agent and managed-subagent definition storage backend ('file' for on-disk layouts, 'db' to share definitions across nodes via SQL).",
),
)
scheduler: SchedulerConfig = Field(

View File

@ -180,6 +180,19 @@ class Paths:
"""
return self.base_dir / "agents"
@property
def managed_subagents_dir(self) -> Path:
"""Deployment-level managed subagent definitions.
Each definition is stored as its own JSON file so an atomic replace
never targets a mounted directory or a single shared manifest file.
"""
return self.base_dir / "managed-subagents"
def managed_subagent_file(self, name: str) -> Path:
"""Path to one managed subagent definition."""
return self.managed_subagents_dir / f"{name.lower()}.json"
def agent_dir(self, name: str) -> Path:
"""Legacy per-agent directory (no user isolation): `{base_dir}/agents/{name}/`."""
return self.agents_dir / name.lower()

View File

@ -72,7 +72,12 @@ def _build_engine(url: str) -> Engine:
return engine
def _get_sessionmaker(url: str) -> sessionmaker[Session]:
def get_sync_sessionmaker(url: str) -> sessionmaker[Session]:
"""Return the process-wide synchronous session factory for ``url``.
Managed subagents use the same SQL engine because both stores are read by
synchronous graph construction code as well as Gateway worker threads.
"""
engine = _engines.get(url)
if engine is None:
with _engines_lock:
@ -83,6 +88,11 @@ def _get_sessionmaker(url: str) -> sessionmaker[Session]:
return sessionmaker(engine, expire_on_commit=False)
# Compatibility for existing internal callers; new shared stores use the
# public name above.
_get_sessionmaker = get_sync_sessionmaker
def _config_document(config: dict) -> dict:
"""Strip the natural key from the stored document (``name`` is its own column)."""
return {k: v for k, v in config.items() if k != "name"}
@ -90,7 +100,7 @@ def _config_document(config: dict) -> dict:
class SqlAgentStore(AgentStore):
def __init__(self, url: str) -> None:
self._Session = _get_sessionmaker(url)
self._Session = get_sync_sessionmaker(url)
def _row(self, session: Session, name: str, user_id: str) -> AgentRow | None:
stmt = select(AgentRow).where(AgentRow.user_id == user_id, AgentRow.name == name.lower())

View File

@ -0,0 +1,58 @@
"""Deployment-level managed subagent persistence."""
from __future__ import annotations
from typing import TYPE_CHECKING
from deerflow.persistence.managed_subagents.base import (
ManagedSubagentDefinition,
ManagedSubagentExistsError,
ManagedSubagentStore,
)
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
if TYPE_CHECKING:
from deerflow.config.app_config import AppConfig
__all__ = [
"ManagedSubagentDefinition",
"ManagedSubagentExistsError",
"ManagedSubagentRow",
"ManagedSubagentStore",
"get_managed_subagent_store",
"make_managed_subagent_store",
]
_file_store_singleton: ManagedSubagentStore | None = None
def make_managed_subagent_store(config: AppConfig) -> ManagedSubagentStore:
"""Select the same persistence backend used by custom agent definitions."""
if config.agent_storage.backend == "db":
if config.database.backend not in ("sqlite", "postgres"):
raise ValueError("Managed subagent database storage requires database.backend to be 'sqlite' or 'postgres'.")
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
return SqlManagedSubagentStore(config.database.app_sync_sqlalchemy_url)
return _file_store()
def get_managed_subagent_store(config: AppConfig | None = None) -> ManagedSubagentStore:
if config is not None:
return make_managed_subagent_store(config)
from deerflow.config.app_config import get_app_config
try:
resolved = get_app_config()
except Exception: # noqa: BLE001 — lightweight/test contexts keep file fallback
return _file_store()
return make_managed_subagent_store(resolved)
def _file_store() -> ManagedSubagentStore:
global _file_store_singleton
if _file_store_singleton is None:
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
_file_store_singleton = FileManagedSubagentStore()
return _file_store_singleton

View File

@ -0,0 +1,116 @@
"""Storage contract for deployment-level managed subagents."""
from __future__ import annotations
import abc
import re
from collections.abc import Hashable
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
REQUIRED_DISALLOWED_TOOLS = frozenset({"task", "ask_clarification", "present_files"})
MANAGED_SUBAGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
def normalize_managed_subagent_name(value: str) -> str:
"""Validate and normalize a managed subagent natural key."""
if not isinstance(value, str) or not MANAGED_SUBAGENT_NAME_PATTERN.fullmatch(value):
raise ValueError(f"Invalid managed subagent name {value!r}. Must match {MANAGED_SUBAGENT_NAME_PATTERN.pattern}")
return value.lower()
class ManagedSubagentDefinition(BaseModel):
"""Administrator-managed worker definition stored outside config.yaml."""
model_config = ConfigDict(extra="forbid")
name: str
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] = Field(default_factory=lambda: sorted(REQUIRED_DISALLOWED_TOOLS))
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
@field_validator("name")
@classmethod
def _normalize_name(cls, value: str) -> str:
return normalize_managed_subagent_name(value)
@field_validator("display_name")
@classmethod
def _normalize_display_name(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
@field_validator("description", "system_prompt")
@classmethod
def _strip_required_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("must not be blank")
return stripped
@field_validator("tools", "skills")
@classmethod
def _validate_name_lists(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized: list[str] = []
for item in value:
stripped = item.strip()
if not stripped:
raise ValueError("entries must not be blank")
if stripped not in normalized:
normalized.append(stripped)
return normalized
@model_validator(mode="after")
def _enforce_worker_tool_boundary(self) -> ManagedSubagentDefinition:
denied = list(dict.fromkeys([*self.disallowed_tools, *sorted(REQUIRED_DISALLOWED_TOOLS)]))
self.disallowed_tools = denied
return self
class ManagedSubagentExistsError(Exception):
"""Raised when a managed definition already owns a name."""
class ManagedSubagentStore(abc.ABC):
def cache_identity(self) -> Hashable:
"""Return the process-local identity of the backing catalog.
Stateless store instances that point at the same backing data should
override this so registry snapshots can be reused across instances.
"""
return id(self)
@abc.abstractmethod
def get(self, name: str) -> ManagedSubagentDefinition:
"""Return one definition or raise ``FileNotFoundError``."""
@abc.abstractmethod
def list(self) -> list[ManagedSubagentDefinition]:
"""Return every managed definition, including disabled ones."""
@abc.abstractmethod
def create(self, definition: ManagedSubagentDefinition) -> None:
"""Create one definition or raise ``ManagedSubagentExistsError``."""
@abc.abstractmethod
def update(self, definition: ManagedSubagentDefinition) -> None:
"""Replace one existing definition or raise ``FileNotFoundError``."""
@abc.abstractmethod
def delete(self, name: str) -> bool:
"""Delete one definition and return whether it existed."""
@abc.abstractmethod
def signature(self) -> Hashable:
"""Return an opaque token suitable for cache invalidation."""

View File

@ -0,0 +1,100 @@
"""File-backed managed subagent store."""
from __future__ import annotations
import logging
import os
import tempfile
import threading
from collections.abc import Hashable
from pathlib import Path
from deerflow.config.paths import get_paths
from deerflow.persistence.managed_subagents.base import (
ManagedSubagentDefinition,
ManagedSubagentExistsError,
ManagedSubagentStore,
normalize_managed_subagent_name,
)
logger = logging.getLogger(__name__)
_write_lock = threading.RLock()
def _normalized_name(name: str) -> str:
return normalize_managed_subagent_name(name)
class FileManagedSubagentStore(ManagedSubagentStore):
def cache_identity(self) -> Hashable:
return ("file", str(get_paths().managed_subagents_dir))
def get(self, name: str) -> ManagedSubagentDefinition:
path = get_paths().managed_subagent_file(_normalized_name(name))
if not path.is_file():
raise FileNotFoundError(f"Managed subagent not found: {name}")
return ManagedSubagentDefinition.model_validate_json(path.read_text(encoding="utf-8"))
def list(self) -> list[ManagedSubagentDefinition]:
root = get_paths().managed_subagents_dir
if not root.exists():
return []
definitions: list[ManagedSubagentDefinition] = []
for path in sorted(root.glob("*.json")):
try:
definitions.append(ManagedSubagentDefinition.model_validate_json(path.read_text(encoding="utf-8")))
except Exception: # noqa: BLE001 — one corrupt definition must not hide the catalog
logger.warning("Skipping invalid managed subagent definition %s", path, exc_info=True)
return sorted(definitions, key=lambda item: item.name)
def create(self, definition: ManagedSubagentDefinition) -> None:
path = get_paths().managed_subagent_file(definition.name)
with _write_lock:
if path.exists():
raise ManagedSubagentExistsError(f"Managed subagent '{definition.name}' already exists")
path.parent.mkdir(parents=True, exist_ok=True)
self._atomic_write(path, definition)
def update(self, definition: ManagedSubagentDefinition) -> None:
path = get_paths().managed_subagent_file(definition.name)
with _write_lock:
if not path.is_file():
raise FileNotFoundError(f"Managed subagent not found: {definition.name}")
self._atomic_write(path, definition)
def delete(self, name: str) -> bool:
path = get_paths().managed_subagent_file(_normalized_name(name))
with _write_lock:
if not path.is_file():
return False
path.unlink()
return True
def signature(self) -> Hashable:
root = get_paths().managed_subagents_dir
if not root.exists():
return ()
signature: list[tuple[str, int, int]] = []
for path in sorted(root.glob("*.json")):
try:
stat = path.stat()
except OSError:
continue
signature.append((path.name, stat.st_mtime_ns, stat.st_size))
return tuple(signature)
@staticmethod
def _atomic_write(path: Path, definition: ManagedSubagentDefinition) -> None:
payload = definition.model_dump_json(indent=2) + "\n"
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, suffix=".tmp", delete=False) as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
tmp_path = Path(handle.name)
os.replace(tmp_path, path)
tmp_path = None
finally:
if tmp_path is not None:
tmp_path.unlink(missing_ok=True)

View File

@ -0,0 +1,24 @@
"""ORM model for deployment-level managed subagent definitions."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ManagedSubagentRow(Base):
__tablename__ = "managed_subagents"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True)
definition: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)

View File

@ -0,0 +1,80 @@
"""SQL-backed managed subagent store."""
from __future__ import annotations
import uuid
from collections.abc import Hashable
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from deerflow.persistence.agents.sql import get_sync_sessionmaker
from deerflow.persistence.managed_subagents.base import (
ManagedSubagentDefinition,
ManagedSubagentExistsError,
ManagedSubagentStore,
normalize_managed_subagent_name,
)
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
def _normalized_name(name: str) -> str:
return normalize_managed_subagent_name(name)
class SqlManagedSubagentStore(ManagedSubagentStore):
def __init__(self, url: str) -> None:
self._url = url
self._Session = get_sync_sessionmaker(url)
def cache_identity(self) -> Hashable:
return ("db", self._url)
def get(self, name: str) -> ManagedSubagentDefinition:
normalized = _normalized_name(name)
with self._Session() as session:
row = session.execute(select(ManagedSubagentRow).where(ManagedSubagentRow.name == normalized)).scalar_one_or_none()
if row is None:
raise FileNotFoundError(f"Managed subagent not found: {name}")
return ManagedSubagentDefinition.model_validate(row.definition)
def list(self) -> list[ManagedSubagentDefinition]:
with self._Session() as session:
rows = list(session.execute(select(ManagedSubagentRow).order_by(ManagedSubagentRow.name.asc())).scalars())
return [ManagedSubagentDefinition.model_validate(row.definition) for row in rows]
def create(self, definition: ManagedSubagentDefinition) -> None:
row = ManagedSubagentRow(
id=uuid.uuid4().hex,
name=definition.name,
definition=definition.model_dump(mode="json"),
)
try:
with self._Session() as session:
session.add(row)
session.commit()
except IntegrityError as exc:
raise ManagedSubagentExistsError(f"Managed subagent '{definition.name}' already exists") from exc
def update(self, definition: ManagedSubagentDefinition) -> None:
with self._Session() as session:
row = session.execute(select(ManagedSubagentRow).where(ManagedSubagentRow.name == definition.name)).scalar_one_or_none()
if row is None:
raise FileNotFoundError(f"Managed subagent not found: {definition.name}")
row.definition = definition.model_dump(mode="json")
session.commit()
def delete(self, name: str) -> bool:
normalized = _normalized_name(name)
with self._Session() as session:
result = session.execute(delete(ManagedSubagentRow).where(ManagedSubagentRow.name == normalized))
session.commit()
return result.rowcount > 0
def signature(self) -> Hashable:
with self._Session() as session:
# COUNT + MAX(updated_at) misses an update from a node whose clock
# trails the current maximum. Preserve each row's timestamp so any
# definition change invalidates peer-process registry snapshots.
rows = session.execute(select(ManagedSubagentRow.id, ManagedSubagentRow.updated_at).order_by(ManagedSubagentRow.id.asc())).all()
return tuple((row_id, updated_at) for row_id, updated_at in rows)

View File

@ -83,6 +83,7 @@ on installs that never enabled it. The convention is:
- `migrations/versions/0011_mcp_tasks.py` — creates the durable long-running MCP task table and its user/server/remote uniqueness constraint
- `migrations/versions/0012_mcp_task_results.py` — adds bounded result preview/truncation/artifact fields for ordinary task drivers
- `migrations/versions/0013_mcp_task_notifications.py` — adds durable Agent-run notification snapshots, delivery leases, idempotency fields, and the separate bounded-retry attempt counter
- `migrations/versions/0014_managed_subagents.py` — creates the deployment-level managed Subagent catalog table
- `persistence/bootstrap.py``bootstrap_schema(engine, backend=...)`, the three-branch decision + locking
- `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()`
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)

View File

@ -0,0 +1,38 @@
"""deployment-level managed subagents.
Revision ID: 0014_managed_subagents
Revises: 0013_mcp_task_notifications
Create Date: 2026-08-18
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0014_managed_subagents"
down_revision: str | Sequence[str] | None = "0013_mcp_task_notifications"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
if sa.inspect(op.get_bind()).has_table("managed_subagents"):
return
op.create_table(
"managed_subagents",
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("definition", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
if sa.inspect(op.get_bind()).has_table("managed_subagents"):
op.drop_table("managed_subagents")

View File

@ -22,6 +22,7 @@ from deerflow.persistence.channel_connections.model import (
ChannelOAuthStateRow,
)
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.mcp_tasks.model import McpTaskRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.run.model import RunRow
@ -39,6 +40,7 @@ __all__ = [
"ChannelOAuthStateRow",
"FeedbackRow",
"McpTaskRow",
"ManagedSubagentRow",
"RunEventRow",
"RunRow",
"ScheduledTaskRow",

View File

@ -1,6 +1,7 @@
### Subagent System (`packages/harness/deerflow/subagents/`)
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
**Registry and managed definitions**: Runtime resolution is built-in → `config.yaml custom_agents` → enabled administrator-managed definitions, followed by explicit `subagents.agents.<name>` overrides. Managed definitions are deployment-wide, persist through the same `agent_storage.backend` selection as Custom Agent definitions, and remain stored but are excluded from runtime when a built-in or later-added config definition owns the same name. The default Lead Agent sees the whole enabled catalog. A Custom Agent's `allowed_subagents` is snapshotted into run metadata (`None` = all, `[]` = hard deny, list = allowlist) and must filter both prompt discovery and `task` execution; never reload caller policy from mutable agent config inside the tool.
**Benefit-based routing policy**: Enabling subagents exposes delegation as an optimization, not a default response to complexity. The lead prompt defaults to direct execution and permits `task` only when parallel latency, specialist capability, or context-isolation benefit clearly exceeds startup, duplicate-discovery, synthesis, state-conflict, and side-effect costs. Inter-agent output dependencies and overlapping mutable state are hard vetoes for parallel dispatch, while duplicate discovery and a cheap direct path remain costs rather than categorical vetoes; a bounded sequential chain may run in one subagent when specialist or context-isolation benefit clearly wins. Parallel scopes must be independent and non-overlapping, the lead uses the fewest useful subagents, and every later batch is re-evaluated while retaining any within-batch parallel benefit. When the enforced per-response limit is 1, the rendered prompt removes parallel and multi-batch benefit guidance and permits delegation only for material specialist or context-isolation benefit. Keep this policy aligned across `lead_agent/prompt.py`, the `task` tool description, and both built-in role descriptions; routing regressions are pinned in `tests/test_subagent_routing_prompt.py`, `tests/test_subagent_prompt_security.py`, and `tests/test_lead_agent_prompt.py`.
**User-scoped Skills**: Subagents resolve their configured skills through `get_or_new_user_skill_storage(user_id)` using the parent runtime identity, with `DEFAULT_USER_ID` only when no identity is available. This keeps custom-skill shadowing and visibility aligned with the lead agent instead of reading the global-only catalog.
**Date context (#4781)**: Every built-in subagent execution registers `SubagentDateContextMiddleware` immediately before `SystemMessageCoalescingMiddleware`. Its one-time `before_agent` hook adds a hidden framework-owned `SystemMessage` containing only `<current_date>` before the first model call; it does not read `AppConfig.memory`, call the memory manager, rewrite the task `HumanMessage`, or inherit the lead agent's frozen-conversation/midnight lifecycle. The coalescer merges that reminder with the subagent's static prompt so strict providers still receive exactly one leading `SystemMessage`. The lead-only `DynamicContextMiddleware` registration and its date, optional-memory, and midnight-update behavior remain unchanged.

View File

@ -1,14 +1,21 @@
"""Subagent registry for managing available subagents."""
import logging
import threading
import time
from collections.abc import Hashable
from dataclasses import replace
from typing import Any
from deerflow.persistence.managed_subagents import ManagedSubagentDefinition, get_managed_subagent_store
from deerflow.sandbox.security import is_host_bash_allowed
from deerflow.subagents.builtins import BUILTIN_SUBAGENTS
from deerflow.subagents.config import SubagentConfig
logger = logging.getLogger(__name__)
_MANAGED_SIGNATURE_TTL_SECONDS = 1.0
_managed_definitions_cache_lock = threading.RLock()
_managed_definitions_cache: dict[Hashable, tuple[float, Hashable, tuple[ManagedSubagentDefinition, ...]]] = {}
def _resolve_subagents_app_config(app_config: Any | None = None):
@ -47,13 +54,63 @@ def _build_custom_subagent_config(name: str, *, app_config: Any | None = None) -
)
def _clear_managed_definitions_cache() -> None:
"""Clear process-local registry snapshots (primarily for tests)."""
with _managed_definitions_cache_lock:
_managed_definitions_cache.clear()
def _managed_definitions(*, app_config: Any | None = None) -> tuple[ManagedSubagentDefinition, ...]:
"""Load and cache deployment-managed definitions until their signature changes."""
store_config = app_config if hasattr(app_config, "agent_storage") else None
store = get_managed_subagent_store(store_config)
cache_key = store.cache_identity()
with _managed_definitions_cache_lock:
checked_at = time.monotonic()
cached = _managed_definitions_cache.get(cache_key)
# A prompt/catalog pass can resolve every managed name separately.
# Avoid repeating the file stat sweep or SQL signature query for each
# lookup while keeping cross-process changes visible within one second.
if cached is not None and checked_at - cached[0] < _MANAGED_SIGNATURE_TTL_SECONDS:
return cached[2]
signature = store.signature()
if cached is not None and cached[1] == signature:
_managed_definitions_cache[cache_key] = (checked_at, signature, cached[2])
return cached[2]
definitions = tuple(store.list())
_managed_definitions_cache[cache_key] = (checked_at, signature, definitions)
return definitions
def _build_managed_subagent_config(name: str, *, app_config: Any | None = None) -> SubagentConfig | None:
for definition in _managed_definitions(app_config=app_config):
if definition.name != name or not definition.enabled:
continue
return SubagentConfig(
name=definition.name,
description=definition.description,
system_prompt=definition.system_prompt,
tools=definition.tools,
disallowed_tools=definition.disallowed_tools,
skills=definition.skills,
model=definition.model,
max_turns=definition.max_turns,
timeout_seconds=definition.timeout_seconds,
)
return None
def get_subagent_config(name: str, *, app_config: Any | None = None) -> SubagentConfig | None:
"""Get a subagent configuration by name, with config.yaml overrides applied.
Resolution order (mirrors Codex's config layering):
1. Built-in subagents (general-purpose, bash)
2. Custom subagents from config.yaml custom_agents section
3. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, skills)
3. Enabled administrator-managed subagents
4. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, skills)
Args:
name: The name of the subagent.
@ -66,6 +123,8 @@ def get_subagent_config(name: str, *, app_config: Any | None = None) -> Subagent
config = BUILTIN_SUBAGENTS.get(name)
if config is None:
config = _build_custom_subagent_config(name, app_config=app_config)
if config is None:
config = _build_managed_subagent_config(name, app_config=app_config)
if config is None:
return None
@ -116,22 +175,22 @@ def get_subagent_config(name: str, *, app_config: Any | None = None) -> Subagent
return config
def list_subagents(*, app_config: Any | None = None) -> list[SubagentConfig]:
def list_subagents(*, app_config: Any | None = None, allowed_subagents: list[str] | None = None) -> list[SubagentConfig]:
"""List all available subagent configurations (with config.yaml overrides applied).
Returns:
List of all registered SubagentConfig instances (built-in + custom).
"""
configs = []
for name in get_subagent_names(app_config=app_config):
for name in get_subagent_names(app_config=app_config, allowed_subagents=allowed_subagents):
config = get_subagent_config(name, app_config=app_config)
if config is not None:
configs.append(config)
return configs
def get_subagent_names(*, app_config: Any | None = None) -> list[str]:
"""Get all available subagent names (built-in + custom).
def get_subagent_names(*, app_config: Any | None = None, allowed_subagents: list[str] | None = None) -> list[str]:
"""Get registered subagent names, optionally restricted by the caller policy.
Returns:
List of subagent names.
@ -144,16 +203,31 @@ def get_subagent_names(*, app_config: Any | None = None) -> list[str]:
if custom_name not in names:
names.append(custom_name)
# Built-in and config.yaml definitions have operator-controlled precedence.
# A managed definition that later conflicts remains persisted for the
# Settings UI, but is excluded from runtime discovery.
for definition in _managed_definitions(app_config=app_config):
if not definition.enabled:
continue
if definition.name in names:
logger.debug("Managed subagent '%s' conflicts with a built-in or config.yaml definition and is excluded from runtime", definition.name)
continue
names.append(definition.name)
if allowed_subagents is not None:
allowed = set(allowed_subagents)
names = [name for name in names if name in allowed]
return names
def get_available_subagent_names(*, app_config: Any | None = None) -> list[str]:
def get_available_subagent_names(*, app_config: Any | None = None, allowed_subagents: list[str] | None = None) -> list[str]:
"""Get subagent names that should be exposed to the active runtime.
Returns:
List of subagent names visible to the current sandbox configuration.
"""
names = get_subagent_names(app_config=app_config)
names = get_subagent_names(app_config=app_config, allowed_subagents=allowed_subagents)
try:
host_bash_allowed = is_host_bash_allowed(app_config) if hasattr(app_config, "sandbox") else is_host_bash_allowed()
except Exception:

View File

@ -268,18 +268,15 @@ async def task_tool(
subagent_type: The type of subagent to use. ALWAYS PROVIDE THIS PARAMETER THIRD.
"""
runtime_app_config = _get_runtime_app_config(runtime)
available_subagent_names = get_available_subagent_names(app_config=runtime_app_config) if runtime_app_config is not None else get_available_subagent_names()
metadata: dict = runtime.config.get("metadata", {}) if runtime is not None else {}
allowed_subagents = metadata.get("allowed_subagents")
if allowed_subagents is None:
available_subagent_names = get_available_subagent_names(app_config=runtime_app_config) if runtime_app_config is not None else get_available_subagent_names()
else:
available_subagent_names = get_available_subagent_names(app_config=runtime_app_config, allowed_subagents=allowed_subagents) if runtime_app_config is not None else get_available_subagent_names(allowed_subagents=allowed_subagents)
# Get subagent configuration
config = get_subagent_config(subagent_type, app_config=runtime_app_config) if runtime_app_config is not None else get_subagent_config(subagent_type)
if config is None:
available = ", ".join(available_subagent_names)
error = f"Unknown subagent type '{subagent_type}'. Available: {available}"
return _task_result_command(
tool_call_id=tool_call_id,
status="failed",
error=error,
)
# Preserve the dedicated sandbox-policy guidance before the generic
# registry/policy membership gate filters bash from the visible catalog.
if subagent_type == "bash":
host_bash_allowed = is_host_bash_allowed(runtime_app_config) if runtime_app_config is not None else is_host_bash_allowed()
if not host_bash_allowed:
@ -289,6 +286,21 @@ async def task_tool(
error=LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE,
)
# Get subagent configuration
config = get_subagent_config(subagent_type, app_config=runtime_app_config) if runtime_app_config is not None else get_subagent_config(subagent_type)
if config is None or subagent_type not in available_subagent_names:
if available_subagent_names:
available = ", ".join(available_subagent_names)
elif allowed_subagents is not None:
available = "none permitted by caller policy"
else:
available = "none"
error = f"Unknown subagent type '{subagent_type}'. Available: {available}"
return _task_result_command(
tool_call_id=tool_call_id,
status="failed",
error=error,
)
# Build config overrides
overrides: dict = {}
@ -304,8 +316,6 @@ async def task_tool(
trace_id = None
user_id = None
deerflow_trace_id = None
metadata: dict = {}
if runtime is not None:
sandbox_state = runtime.state.get("sandbox")
thread_data = runtime.state.get("thread_data")
@ -314,7 +324,6 @@ async def task_tool(
thread_id = runtime.config.get("configurable", {}).get("thread_id")
# Try to get parent model from configurable
metadata = runtime.config.get("metadata", {})
parent_model = metadata.get("model_name")
# Get or generate trace_id for distributed tracing

View File

@ -53,10 +53,11 @@ _NULLISH_STRINGS = frozenset({"null", "none", "undefined"})
# expose self-mutation over a webhook.
_UNTRUSTED_CHANNELS: frozenset[str] = frozenset({"github"})
_MODEL_BEHAVIOR_FIELDS: tuple[str, ...] = (
_UI_OWNED_CONFIG_FIELDS: tuple[str, ...] = (
"model_settings",
"thinking_enabled",
"reasoning_effort",
"allowed_subagents",
)
@ -209,12 +210,12 @@ def update_agent(
if skills is not None and skills != existing_cfg.skills:
updated_fields.append("skills")
# This tool intentionally does not expose the #4336 model-behavior fields
# as LLM-callable arguments yet, but it still rewrites config.yaml when any
# of its supported fields changes. Carry those values forward explicitly so
# an agent refining its description/model/skills cannot erase UI/API-owned
# defaults such as temperature or reasoning effort.
for key in _MODEL_BEHAVIOR_FIELDS:
# This tool intentionally does not expose these UI/API-owned fields as
# LLM-callable arguments, but it still rewrites config.yaml when any of its
# supported fields changes. Carry them forward explicitly so an agent
# refining its description/model/skills cannot erase model defaults or its
# server-enforced subagent policy.
for key in _UI_OWNED_CONFIG_FIELDS:
value = getattr(existing_cfg, key, None)
if value is None:
continue

View File

@ -1,15 +1,17 @@
#!/usr/bin/env python
"""One-shot importer: copy file-backed custom agents into the ``db`` agent store.
"""One-shot importer: copy file-backed agent definitions into ``db`` stores.
For operators switching ``agent_storage.backend`` from ``file`` to ``db``. Reads
every agent from the on-disk layout (both the per-user
every Custom Agent from the on-disk layout (both the per-user
``{base_dir}/users/{user_id}/agents/`` and the legacy shared
``{base_dir}/agents/``, with the same shadowing rule the file store uses) and
writes each as a row in the shared ``agents`` table.
writes each as a row in the shared ``agents`` table. It also copies every
deployment-level definition from ``{base_dir}/managed-subagents/`` into the
shared ``managed_subagents`` table.
Design (mirrors ``scripts/migrate_user_isolation.py``):
- Explicit, operator-run. Nothing auto-imports on boot.
- Idempotent: an agent already present in the db is skipped, so re-running is safe.
- Idempotent: a definition already present in the db is skipped, so re-running is safe.
- Non-destructive: the on-disk files are left untouched, so unsetting
``agent_storage.backend`` (back to ``file``) is a clean rollback.
@ -31,12 +33,15 @@ from deerflow.config.app_config import get_app_config
from deerflow.persistence.agents.base import AgentExistsError
from deerflow.persistence.agents.file import FileAgentStore
from deerflow.persistence.agents.sql import SqlAgentStore
from deerflow.persistence.managed_subagents.base import ManagedSubagentExistsError
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
logger = logging.getLogger("migrate_agents_to_db")
def main() -> int:
parser = argparse.ArgumentParser(description="Import file-backed custom agents into the db agent store.")
parser = argparse.ArgumentParser(description="Import file-backed Custom Agents and managed subagents into db stores.")
parser.add_argument("--dry-run", action="store_true", help="List what would be imported without writing to the database.")
args = parser.parse_args()
@ -51,41 +56,67 @@ def main() -> int:
)
return 1
source = FileAgentStore()
agents = source.list_all()
if not agents:
logger.info("No file-backed agents found — nothing to import.")
agent_source = FileAgentStore()
agents = agent_source.list_all()
managed_source = FileManagedSubagentStore()
managed_definitions = managed_source.list()
if not agents and not managed_definitions:
logger.info("No file-backed Custom Agents or managed subagents found — nothing to import.")
return 0
if args.dry_run:
for user_id, cfg in agents:
logger.info("[dry-run] would import %s/%s", user_id, cfg.name)
logger.info("[dry-run] %d agent(s) would be imported. Source files are left in place.", len(agents))
logger.info("[dry-run] would import Custom Agent %s/%s", user_id, cfg.name)
for definition in managed_definitions:
logger.info("[dry-run] would import managed subagent %s", definition.name)
logger.info(
"[dry-run] %d Custom Agent(s) and %d managed subagent(s) would be imported. Source files are left in place.",
len(agents),
len(managed_definitions),
)
return 0
# Ensure the schema exists (creates the ``agents`` table via the same
# Alembic bootstrap the gateway runs) before the sync store writes rows.
# Ensure the schema exists (creates both definition tables via the same
# Alembic bootstrap the gateway runs) before the sync stores write rows.
from deerflow.persistence.engine import init_engine_from_config
asyncio.run(init_engine_from_config(config.database))
dest = SqlAgentStore(config.database.app_sync_sqlalchemy_url)
imported = 0
skipped = 0
agent_dest = SqlAgentStore(config.database.app_sync_sqlalchemy_url)
managed_dest = SqlManagedSubagentStore(config.database.app_sync_sqlalchemy_url)
imported_agents = 0
skipped_agents = 0
for user_id, cfg in agents:
soul = source.get_soul(cfg.name, user_id=user_id) or ""
soul = agent_source.get_soul(cfg.name, user_id=user_id) or ""
# exclude_unset keeps the stored document as sparse as the source file
# (only the keys the operator actually wrote), matching the file layout.
document = cfg.model_dump(exclude_unset=True)
try:
dest.create(cfg.name, document, soul, user_id=user_id)
imported += 1
logger.info("imported %s/%s", user_id, cfg.name)
agent_dest.create(cfg.name, document, soul, user_id=user_id)
imported_agents += 1
logger.info("imported Custom Agent %s/%s", user_id, cfg.name)
except AgentExistsError:
skipped += 1
logger.info("skip %s/%s: already present in db", user_id, cfg.name)
skipped_agents += 1
logger.info("skip Custom Agent %s/%s: already present in db", user_id, cfg.name)
logger.info("Done: %d imported, %d already present. Source files left in place (rollback: revert agent_storage.backend to 'file').", imported, skipped)
imported_managed = 0
skipped_managed = 0
for definition in managed_definitions:
try:
managed_dest.create(definition)
imported_managed += 1
logger.info("imported managed subagent %s", definition.name)
except ManagedSubagentExistsError:
skipped_managed += 1
logger.info("skip managed subagent %s: already present in db", definition.name)
logger.info(
"Done: Custom Agents: %d imported, %d already present; managed subagents: %d imported, %d already present. Source files left in place (rollback: revert agent_storage.backend to 'file').",
imported_agents,
skipped_agents,
imported_managed,
skipped_managed,
)
return 0

View File

@ -181,6 +181,41 @@ class TestLeadAgentAssembly:
assert assembly.descriptor.effective_model
assert assembly.descriptor.fingerprint
def test_descriptor_hashes_the_same_scoped_prompt_passed_to_the_graph(self, monkeypatch):
from deerflow_extension_api import canonical_hash
from deerflow.agents.lead_agent import agent as lead_agent_module
from deerflow.agents.lead_agent.agent import assemble_lead_agent
from deerflow.config.agents_config import AgentConfig
from deerflow.extensions import bind_agent_build_extensions
self._isolate_from_the_ambient_config(monkeypatch)
agent_config = AgentConfig(name="custom", allowed_subagents=["general-purpose"])
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda name, *, user_id=None: agent_config)
prompt_calls = []
def render_prompt(**kwargs):
prompt_calls.append(kwargs)
return f"allowed_subagents={kwargs['allowed_subagents']}"
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", render_prompt)
with bind_agent_build_extensions(self._extensions_with_an_agent_assembly_observer()):
assembly = assemble_lead_agent(
{
"configurable": {
"thread_id": "t-scoped-prompt",
"agent_name": "custom",
"subagent_enabled": True,
}
}
)
assert len(prompt_calls) == 1
assert prompt_calls[0]["allowed_subagents"] == ["general-purpose"]
assert assembly.graph["system_prompt"] == "allowed_subagents=['general-purpose']"
assert assembly.descriptor.base_prompt_hash == canonical_hash(assembly.graph["system_prompt"])
def test_observers_receive_the_descriptor(self, monkeypatch):
from deerflow.agents.lead_agent.agent import assemble_lead_agent
from deerflow.extensions import bind_agent_build_extensions

View File

@ -81,7 +81,7 @@ def test_model_settings_are_managed_fields() -> None:
# preserve_non_managed_fields helper must not also carry them. Surfaces that
# do not expose them directly, such as the harness update_agent tool, need a
# dedicated carry-forward path instead.
for field in ("model_settings", "thinking_enabled", "reasoning_effort"):
for field in ("model_settings", "thinking_enabled", "reasoning_effort", "allowed_subagents"):
assert field in MANAGED_AGENT_CONFIG_FIELDS

View File

@ -18,6 +18,10 @@ from deerflow.persistence.agents.file import FileAgentStore
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.agents.sql import SqlAgentStore
from deerflow.persistence.base import Base
from deerflow.persistence.managed_subagents.base import ManagedSubagentDefinition
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
def _cfg(agent_backend: str, db_backend: str, sqlite_dir: str = "/tmp/agent-store-test") -> SimpleNamespace:
@ -72,7 +76,7 @@ def test_validation_warns_on_file_under_multiworker_postgres(monkeypatch, caplog
@pytest.fixture()
def file_home(tmp_path, monkeypatch):
"""Root the file store at a temp DEER_FLOW_HOME with two seeded agents."""
"""Root file stores at a temp DEER_FLOW_HOME with seeded definitions."""
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
from deerflow.config import paths as paths_module
@ -80,6 +84,14 @@ def file_home(tmp_path, monkeypatch):
fs = FileAgentStore()
fs.create("reviewer", {"name": "reviewer", "description": "reviews"}, "review soul", user_id="u1")
fs.create("planner", {"name": "planner", "description": "plans", "model": "m1"}, "plan soul", user_id="u2")
FileManagedSubagentStore().create(
ManagedSubagentDefinition(
name="researcher",
description="Researches topics",
system_prompt="Research carefully.",
enabled=False,
)
)
return tmp_path
@ -95,7 +107,7 @@ def _patch_importer(monkeypatch, cfg):
# does, which also creates the sqlite directory).
pathlib.Path(cfg.database.sqlite_dir).mkdir(parents=True, exist_ok=True)
engine = create_engine(cfg.database.app_sync_sqlalchemy_url)
Base.metadata.create_all(engine, tables=[AgentRow.__table__])
Base.metadata.create_all(engine, tables=[AgentRow.__table__, ManagedSubagentRow.__table__])
engine.dispose()
monkeypatch.setattr(importer, "get_app_config", lambda: cfg)
@ -103,7 +115,7 @@ def _patch_importer(monkeypatch, cfg):
return importer
def test_importer_copies_all_agents_into_db(file_home, monkeypatch):
def test_importer_copies_all_definitions_into_db(file_home, monkeypatch):
cfg = _cfg("db", "sqlite", str(file_home / "db"))
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
@ -114,6 +126,9 @@ def test_importer_copies_all_agents_into_db(file_home, monkeypatch):
assert dest.get("reviewer", user_id="u1").description == "reviews"
assert dest.get_soul("reviewer", user_id="u1") == "review soul"
assert dest.get("planner", user_id="u2").model == "m1"
managed = SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).get("researcher")
assert managed.description == "Researches topics"
assert managed.enabled is False
def test_importer_is_idempotent(file_home, monkeypatch):
@ -126,6 +141,7 @@ def test_importer_is_idempotent(file_home, monkeypatch):
assert importer.main() == 0
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
assert len(dest.list_all()) == 2
assert [item.name for item in SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).list()] == ["researcher"]
def test_importer_dry_run_writes_nothing(file_home, monkeypatch):
@ -136,6 +152,30 @@ def test_importer_dry_run_writes_nothing(file_home, monkeypatch):
assert importer.main() == 0
dest = SqlAgentStore(cfg.database.app_sync_sqlalchemy_url)
assert dest.list_all() == []
assert SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).list() == []
def test_importer_runs_when_only_managed_subagents_exist(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", None)
FileManagedSubagentStore().create(
ManagedSubagentDefinition(
name="writer",
description="Writes reports",
system_prompt="Write clearly.",
)
)
cfg = _cfg("db", "sqlite", str(tmp_path / "db"))
importer = _patch_importer(monkeypatch, cfg)
monkeypatch.setattr(sys, "argv", ["migrate_agents_to_db"])
assert importer.main() == 0
assert SqlAgentStore(cfg.database.app_sync_sqlalchemy_url).list_all() == []
assert SqlManagedSubagentStore(cfg.database.app_sync_sqlalchemy_url).get("writer").description == "Writes reports"
def test_read_free_functions_dispatch_to_db_backend(file_home, monkeypatch):

View File

@ -144,3 +144,14 @@ async def test_update_rejects_unknown_model(_agent_env) -> None:
with pytest.raises(HTTPException) as excinfo:
await update_agent("researcher", AgentUpdateRequest(model="ghost-model"))
assert excinfo.value.status_code == 422
async def test_allowed_subagents_round_trip_and_explicit_null_clears(_agent_env) -> None:
created = await create_agent_endpoint(AgentCreateRequest(name="delegator", allowed_subagents=["planner"]))
assert created.allowed_subagents == ["planner"]
denied = await update_agent("delegator", AgentUpdateRequest(allowed_subagents=[]))
assert denied.allowed_subagents == []
unrestricted = await update_agent("delegator", AgentUpdateRequest(allowed_subagents=None))
assert unrestricted.allowed_subagents is None

View File

@ -185,12 +185,14 @@ def test_make_lead_agent_uses_server_auth_identity_for_all_user_scoped_inputs(mo
def test_make_lead_agent_scopes_bootstrap_middlewares_to_custom_agent(monkeypatch):
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])
captured: dict[str, object] = {}
middleware_calls: list[dict[str, object]] = []
prompt_calls: list[dict[str, object]] = []
import deerflow.tools as tools_module
monkeypatch.setattr(lead_agent_module, "_load_enabled_available_skills", lambda *args, **kwargs: [])
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: captured.update(kwargs) or [])
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: middleware_calls.append(kwargs) or [])
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: prompt_calls.append(kwargs) or "system prompt")
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
@ -201,7 +203,9 @@ def test_make_lead_agent_scopes_bootstrap_middlewares_to_custom_agent(monkeypatc
app_config=app_config,
)
assert captured["agent_name"] == "game"
assert len(middleware_calls) == 1
assert middleware_calls[0]["agent_name"] == "game"
assert len(prompt_calls) == 1
def test_make_lead_agent_attaches_tracing_callbacks_at_graph_root(monkeypatch):
@ -1308,6 +1312,39 @@ def test_request_thinking_overrides_agent_default(monkeypatch):
assert captured["thinking_enabled"] is True # request wins over agent's False
def test_empty_allowed_subagents_disables_requested_delegation(monkeypatch):
"""A request switch cannot widen an explicit Custom Agent hard deny."""
app_config = _make_app_config([_make_model("agent-model", supports_thinking=False)])
agent_config = _make_agent_config(model="agent-model", allowed_subagents=[])
import deerflow.tools as tools_module
get_available_tools = MagicMock(return_value=[])
monkeypatch.setattr(lead_agent_module, "load_agent_config", lambda name, *, user_id=None: agent_config)
monkeypatch.setattr(tools_module, "get_available_tools", get_available_tools)
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: [])
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
config = {
"context": {
"agent_name": "researcher",
"subagent_enabled": True,
}
}
lead_agent_module._make_lead_agent(config, app_config=app_config)
get_available_tools.assert_called_once_with(
model_name="agent-model",
groups=None,
subagent_enabled=False,
app_config=app_config,
)
assert config["context"]["subagent_enabled"] is False
assert config["configurable"]["subagent_enabled"] is False
assert config["metadata"]["allowed_subagents"] == []
def test_make_lead_agent_no_agent_settings_passes_none_overrides(monkeypatch):
"""Without a custom agent, model_overrides is None (no behavior change)."""
app_config = _make_app_config([_make_model("safe-model", supports_thinking=False)])

View File

@ -0,0 +1,187 @@
"""Runtime precedence and caller filtering for managed subagents."""
from __future__ import annotations
import time
from concurrent.futures import ThreadPoolExecutor
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentOverrideConfig, SubagentsAppConfig
from deerflow.persistence.managed_subagents import ManagedSubagentDefinition
from deerflow.subagents import registry
def _managed(name: str, *, enabled: bool = True) -> ManagedSubagentDefinition:
return ManagedSubagentDefinition(
name=name,
description=f"Managed {name}",
system_prompt=f"You are {name}.",
enabled=enabled,
)
def test_enabled_managed_definitions_join_runtime_catalog(monkeypatch):
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [_managed("planner"), _managed("disabled", enabled=False)])
config = SubagentsAppConfig()
assert "planner" in registry.get_subagent_names(app_config=config)
assert "disabled" not in registry.get_subagent_names(app_config=config)
assert registry.get_subagent_config("planner", app_config=config).system_prompt == "You are planner."
def test_default_lead_catalog_preserves_builtin_defaults(monkeypatch):
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [_managed("planner")])
config = SubagentsAppConfig()
assert registry.get_subagent_names(app_config=config) == ["general-purpose", "bash", "planner"]
general = registry.get_subagent_config("general-purpose", app_config=config)
assert general is not None
assert general.tools is None
assert set(general.disallowed_tools or []) == {"task", "ask_clarification", "present_files"}
assert general.model == "inherit"
assert general.max_turns == 150
assert general.timeout_seconds == 1800
bash = registry.get_subagent_config("bash", app_config=config)
assert bash is not None
assert bash.tools == ["bash", "ls", "read_file", "write_file", "str_replace"]
assert set(bash.disallowed_tools or []) == {"task", "ask_clarification", "present_files"}
assert bash.model == "inherit"
assert bash.max_turns == 60
assert bash.timeout_seconds == 1800
def test_builtin_and_config_definitions_win_name_conflicts(monkeypatch):
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [_managed("general-purpose"), _managed("reviewer")])
config = SubagentsAppConfig(
custom_agents={
"reviewer": CustomSubagentConfig(description="Config reviewer", system_prompt="Config wins."),
}
)
names = registry.get_subagent_names(app_config=config)
assert names.count("general-purpose") == 1
assert names.count("reviewer") == 1
assert registry.get_subagent_config("reviewer", app_config=config).system_prompt == "Config wins."
def test_allowed_subagents_is_a_hard_runtime_filter(monkeypatch):
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [_managed("planner"), _managed("writer")])
config = SubagentsAppConfig()
assert registry.get_subagent_names(app_config=config, allowed_subagents=[]) == []
assert registry.get_subagent_names(app_config=config, allowed_subagents=["planner"]) == ["planner"]
def test_config_yaml_overrides_remain_explicitly_higher_priority(monkeypatch):
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [_managed("planner")])
config = SubagentsAppConfig(
agents={"planner": SubagentOverrideConfig(model="configured-model", max_turns=12)},
)
resolved = registry.get_subagent_config("planner", app_config=config)
assert resolved.model == "configured-model"
assert resolved.max_turns == 12
def test_managed_definitions_cache_reuses_and_invalidates_store_snapshot(monkeypatch):
class FakeStore:
def __init__(self):
self.revision = 1
self.definitions = [_managed("planner")]
self.signature_calls = 0
self.list_calls = 0
def signature(self):
self.signature_calls += 1
return self.revision
def cache_identity(self):
return "fake-managed-subagent-store"
def list(self):
self.list_calls += 1
return self.definitions
store = FakeStore()
config = SubagentsAppConfig()
now = [100.0]
registry._clear_managed_definitions_cache()
monkeypatch.setattr(registry, "get_managed_subagent_store", lambda *_: store)
monkeypatch.setattr(time, "monotonic", lambda: now[0])
assert "planner" in registry.get_subagent_names(app_config=config)
assert registry.get_subagent_config("planner", app_config=config).description == "Managed planner"
assert store.signature_calls == 1
assert store.list_calls == 1
store.revision = 2
store.definitions = [_managed("writer")]
now[0] += registry._MANAGED_SIGNATURE_TTL_SECONDS
assert "writer" in registry.get_subagent_names(app_config=config)
assert "planner" not in registry.get_subagent_names(app_config=config)
assert store.signature_calls == 2
assert store.list_calls == 2
def test_list_subagents_checks_managed_signature_once_per_ttl_window(monkeypatch):
class FakeStore:
def __init__(self):
self.signature_calls = 0
self.list_calls = 0
self.definitions = [_managed(f"worker-{index}") for index in range(25)]
def signature(self):
self.signature_calls += 1
return 1
def cache_identity(self):
return "signature-ttl-managed-subagent-store"
def list(self):
self.list_calls += 1
return self.definitions
store = FakeStore()
config = SubagentsAppConfig()
registry._clear_managed_definitions_cache()
monkeypatch.setattr(registry, "get_managed_subagent_store", lambda *_: store)
monkeypatch.setattr(time, "monotonic", lambda: 100.0)
configs = registry.list_subagents(app_config=config)
assert len(configs) == 27
assert store.signature_calls == 1
assert store.list_calls == 1
def test_managed_definitions_cache_serializes_concurrent_first_load(monkeypatch):
class FakeStore:
def __init__(self):
self.signature_calls = 0
self.list_calls = 0
def signature(self):
self.signature_calls += 1
return 1
def cache_identity(self):
return "concurrent-managed-subagent-store"
def list(self):
self.list_calls += 1
return [_managed("planner")]
store = FakeStore()
config = SubagentsAppConfig()
registry._clear_managed_definitions_cache()
monkeypatch.setattr(registry, "get_managed_subagent_store", lambda *_: store)
monkeypatch.setattr(time, "monotonic", lambda: 100.0)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: registry.get_subagent_names(app_config=config), range(16)))
assert all("planner" in names for names in results)
assert store.signature_calls == 1
assert store.list_calls == 1

View File

@ -0,0 +1,139 @@
"""Persistence coverage for administrator-managed subagents."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine, select
from deerflow.persistence.base import Base
from deerflow.persistence.managed_subagents import (
ManagedSubagentDefinition,
ManagedSubagentExistsError,
make_managed_subagent_store,
)
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.managed_subagents.sql import SqlManagedSubagentStore
def _definition(name: str = "researcher", **changes) -> ManagedSubagentDefinition:
return ManagedSubagentDefinition(
name=name,
description="Researches a bounded topic",
system_prompt="You are a research specialist.",
**changes,
)
@pytest.fixture()
def file_store(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
return FileManagedSubagentStore()
@pytest.fixture()
def sql_store(tmp_path):
url = f"sqlite:///{tmp_path}/managed.db"
engine = create_engine(url)
Base.metadata.create_all(engine, tables=[ManagedSubagentRow.__table__])
engine.dispose()
return SqlManagedSubagentStore(url)
@pytest.mark.parametrize("store_fixture", ["file_store", "sql_store"])
def test_crud_and_signature(request, store_fixture):
store = request.getfixturevalue(store_fixture)
empty_signature = store.signature()
definition = _definition()
store.create(definition)
assert store.get("RESEARCHER") == definition
assert [item.name for item in store.list()] == ["researcher"]
created_signature = store.signature()
assert created_signature != empty_signature
updated = definition.model_copy(update={"enabled": False, "max_turns": 75})
store.update(updated)
assert store.get("researcher").enabled is False
assert store.get("researcher").max_turns == 75
updated_signature = store.signature()
assert updated_signature != created_signature
assert store.delete("researcher") is True
assert store.signature() != updated_signature
assert store.delete("researcher") is False
with pytest.raises(FileNotFoundError):
store.get("researcher")
def test_sql_signature_detects_update_behind_existing_max_timestamp(sql_store):
sql_store.create(_definition("ahead"))
sql_store.create(_definition("writer"))
with sql_store._Session() as session:
ahead = session.execute(select(ManagedSubagentRow).where(ManagedSubagentRow.name == "ahead")).scalar_one()
ahead.updated_at = datetime.now(UTC) + timedelta(minutes=5)
session.commit()
before = sql_store.signature()
writer = sql_store.get("writer").model_copy(update={"description": "Updated by a trailing node"})
sql_store.update(writer)
assert sql_store.get("writer").description == "Updated by a trailing node"
assert sql_store.signature() != before
@pytest.mark.parametrize("store_fixture", ["file_store", "sql_store"])
def test_duplicate_create_is_a_conflict(request, store_fixture):
store = request.getfixturevalue(store_fixture)
store.create(_definition())
with pytest.raises(ManagedSubagentExistsError):
store.create(_definition())
def test_worker_boundary_is_always_enforced():
definition = _definition(disallowed_tools=[])
assert {"task", "ask_clarification", "present_files"}.issubset(definition.disallowed_tools)
def test_file_store_uses_one_atomic_file_per_definition(file_store, tmp_path):
file_store.create(_definition("planner"))
file_store.create(_definition("writer"))
root = tmp_path / "managed-subagents"
assert sorted(path.name for path in root.iterdir()) == ["planner.json", "writer.json"]
assert not list(root.glob("*.tmp"))
def test_file_store_skips_one_corrupt_definition_without_hiding_valid_entries(file_store, tmp_path):
file_store.create(_definition("planner"))
root = tmp_path / "managed-subagents"
(root / "broken.json").write_text("{not-json", encoding="utf-8")
assert [item.name for item in file_store.list()] == ["planner"]
def test_store_factory_follows_agent_storage_backend(tmp_path):
file_config = SimpleNamespace(agent_storage=SimpleNamespace(backend="file"))
assert isinstance(make_managed_subagent_store(file_config), FileManagedSubagentStore)
db_config = SimpleNamespace(
agent_storage=SimpleNamespace(backend="db"),
database=SimpleNamespace(
backend="sqlite",
app_sync_sqlalchemy_url=f"sqlite:///{tmp_path}/factory.db",
),
)
assert isinstance(make_managed_subagent_store(db_config), SqlManagedSubagentStore)
def test_store_factory_rejects_memory_database():
config = SimpleNamespace(
agent_storage=SimpleNamespace(backend="db"),
database=SimpleNamespace(backend="memory"),
)
with pytest.raises(ValueError, match="sqlite.*postgres"):
make_managed_subagent_store(config)

View File

@ -157,7 +157,7 @@ async def test_migration_dedupes_duplicate_active_rows_before_unique_index(tmp_p
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
# Bootstrap upgrades through the later revisions after 0004.
assert version_row[0] == "0013_mcp_task_notifications"
assert version_row[0] == "0014_managed_subagents"
# Sanity: the invariant the index enforces is now true — at most one
# active row per thread.

View File

@ -169,7 +169,7 @@ async def test_migration_supersedes_duplicate_active_runs_before_unique_index(tm
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0013_mcp_task_notifications"
assert version_row[0] == "0014_managed_subagents"
# Sanity: the invariant the index enforces now holds — at most one
# active row per task_id.

View File

@ -48,7 +48,7 @@ from deerflow.persistence.migrations._helpers import _normalize_default
asyncio_test = pytest.mark.asyncio
HEAD = "0013_mcp_task_notifications"
HEAD = "0014_managed_subagents"
BASELINE = "0001_baseline"

View File

@ -28,7 +28,7 @@ from deerflow.persistence.bootstrap import bootstrap_schema
pytestmark = pytest.mark.asyncio
HEAD = "0013_mcp_task_notifications"
HEAD = "0014_managed_subagents"
def _url(tmp_path: Path) -> str:

View File

@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0013_mcp_task_notifications"
assert version_row[0] == "0014_managed_subagents"
# And the read path that originally 500'd must now succeed.
sf = get_session_factory()
@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0013_mcp_task_notifications"
assert version_row[0] == "0014_managed_subagents"
finally:
await close_engine()

View File

@ -1,5 +1,7 @@
"""Tests for subagent availability and prompt exposure under local bash hardening."""
from types import SimpleNamespace
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.subagents import registry as registry_module
@ -43,6 +45,29 @@ def test_build_subagent_section_includes_bash_when_available(monkeypatch) -> Non
assert "available tools (bash, ls, read_file, web_search, etc.)" in section
def test_build_subagent_section_lists_only_caller_allowlisted_subagents(monkeypatch) -> None:
def available(*, allowed_subagents):
return [name for name in ["planner", "writer"] if name in allowed_subagents]
monkeypatch.setattr(prompt_module, "get_available_subagent_names", available)
monkeypatch.setattr(
registry_module,
"get_subagent_config",
lambda name, *, app_config=None: SimpleNamespace(description=f"Managed {name}"),
)
section = prompt_module._build_subagent_section(3, allowed_subagents=["planner"])
assert "**planner**" in section
assert "**writer**" not in section
def test_build_subagent_section_is_empty_for_explicit_hard_deny(monkeypatch) -> None:
monkeypatch.setattr(prompt_module, "get_available_subagent_names", lambda *, allowed_subagents: allowed_subagents)
assert prompt_module._build_subagent_section(3, allowed_subagents=[]) == ""
def test_bash_subagent_prompt_mentions_workspace_relative_paths() -> None:
from deerflow.subagents.builtins.bash_agent import BASH_AGENT_CONFIG

View File

@ -0,0 +1,217 @@
"""Gateway catalog visibility and administrator write boundaries."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.gateway.routers import subagents as router
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentsAppConfig
from deerflow.persistence.managed_subagents.file import FileManagedSubagentStore
pytestmark = pytest.mark.asyncio
def _request(role: str):
return SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role=role)))
@pytest.fixture(autouse=True)
def _environment(tmp_path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
set_app_config(AppConfig(sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider")))
store = FileManagedSubagentStore()
monkeypatch.setattr(router, "get_managed_subagent_store", lambda *_: store)
yield
reset_app_config()
async def test_admin_can_create_update_and_delete_managed_subagent():
created = await router.create_managed_subagent(
_request("admin"),
router.ManagedSubagentCreateRequest(
name="planner",
description="Plans creative work",
system_prompt="You are a creative planner.",
),
)
assert created.source == "managed"
assert created.system_prompt == "You are a creative planner."
updated = await router.update_managed_subagent(
"planner",
_request("admin"),
router.ManagedSubagentUpdateRequest(enabled=False),
)
assert updated.enabled is False
await router.delete_managed_subagent("planner", _request("admin"))
with pytest.raises(HTTPException) as excinfo:
await router.delete_managed_subagent("planner", _request("admin"))
assert excinfo.value.status_code == 404
async def test_ordinary_user_can_list_but_cannot_read_prompts_or_write():
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
subagents=SubagentsAppConfig(
custom_agents={
"config-worker": CustomSubagentConfig(
description="Configured worker",
system_prompt="Secret config prompt.",
)
}
),
)
)
await router.create_managed_subagent(
_request("admin"),
router.ManagedSubagentCreateRequest(
name="writer",
description="Writes copy",
system_prompt="Secret worker prompt.",
),
)
catalog = await router.list_subagents(_request("user"))
assert {item.source for item in catalog.subagents} == {
"builtin",
"config",
"managed",
}
assert all(item.system_prompt is None for item in catalog.subagents)
with pytest.raises(HTTPException) as excinfo:
await router.update_managed_subagent(
"writer",
_request("user"),
router.ManagedSubagentUpdateRequest(enabled=False),
)
assert excinfo.value.status_code == 403
with pytest.raises(HTTPException) as excinfo:
await router.create_managed_subagent(
_request("user"),
router.ManagedSubagentCreateRequest(
name="planner",
description="Plans work",
system_prompt="Plan the work.",
),
)
assert excinfo.value.status_code == 403
with pytest.raises(HTTPException) as excinfo:
await router.delete_managed_subagent("writer", _request("user"))
assert excinfo.value.status_code == 403
async def test_read_redaction_and_write_authorization_share_admin_fallback(monkeypatch):
await router.create_managed_subagent(
_request("admin"),
router.ManagedSubagentCreateRequest(
name="writer",
description="Writes copy",
system_prompt="Admin-visible worker prompt.",
),
)
request_without_middleware_user = SimpleNamespace(state=SimpleNamespace())
async def resolve_admin(_request):
return SimpleNamespace(system_role="admin")
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", resolve_admin)
catalog = await router.list_subagents(request_without_middleware_user)
writer = next(item for item in catalog.subagents if item.name == "writer")
assert writer.system_prompt == "Admin-visible worker prompt."
updated = await router.update_managed_subagent(
"writer",
request_without_middleware_user,
router.ManagedSubagentUpdateRequest(enabled=False),
)
assert updated.enabled is False
async def test_builtin_name_is_rejected_at_create():
with pytest.raises(HTTPException) as excinfo:
await router.create_managed_subagent(
_request("admin"),
router.ManagedSubagentCreateRequest(
name="general-purpose",
description="Duplicate",
system_prompt="Duplicate.",
),
)
assert excinfo.value.status_code == 409
async def test_config_name_is_rejected_at_create():
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
subagents=SubagentsAppConfig(
custom_agents={
"planner": CustomSubagentConfig(
description="Config planner",
system_prompt="Config owns this name.",
)
}
),
)
)
with pytest.raises(HTTPException) as excinfo:
await router.create_managed_subagent(
_request("admin"),
router.ManagedSubagentCreateRequest(
name="planner",
description="Duplicate",
system_prompt="Duplicate.",
),
)
assert excinfo.value.status_code == 409
async def test_catalog_marks_config_definition_shadowed_by_builtin_as_conflict():
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
subagents=SubagentsAppConfig(
custom_agents={
"general-purpose": CustomSubagentConfig(
description="Shadowed config worker",
system_prompt="This definition must not win.",
)
}
),
)
)
catalog = await router.list_subagents(_request("admin"))
same_name = [item for item in catalog.subagents if item.name == "general-purpose"]
assert [(item.source, item.conflict) for item in same_name] == [
("builtin", False),
("config", True),
]
@pytest.mark.parametrize("operation", ["update", "delete"])
async def test_invalid_path_name_returns_422(operation: str):
with pytest.raises(HTTPException) as excinfo:
if operation == "update":
await router.update_managed_subagent(
"../planner",
_request("admin"),
router.ManagedSubagentUpdateRequest(enabled=False),
)
else:
await router.delete_managed_subagent("../planner", _request("admin"))
assert excinfo.value.status_code == 422

View File

@ -261,6 +261,51 @@ def test_task_tool_returns_error_for_unknown_subagent(monkeypatch):
assert message.additional_kwargs[SUBAGENT_ERROR_KEY] == "Unknown subagent type 'general-purpose'. Available: general-purpose"
def test_task_tool_enforces_caller_subagent_snapshot(monkeypatch):
runtime = _make_runtime()
runtime.config["metadata"]["allowed_subagents"] = ["planner"]
captured = {}
def available(*, allowed_subagents):
captured["allowed"] = allowed_subagents
return ["planner"]
monkeypatch.setattr(task_tool_module, "get_available_subagent_names", available)
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
result = _run_task_tool(
runtime=runtime,
description="blocked delegation",
prompt="do work",
subagent_type="general-purpose",
tool_call_id="tc-policy",
)
message = _task_tool_message(result)
assert captured["allowed"] == ["planner"]
assert message.additional_kwargs[SUBAGENT_STATUS_KEY] == "failed"
assert "Available: planner" in message.content
def test_task_tool_explains_when_caller_policy_permits_no_subagents(monkeypatch):
runtime = _make_runtime()
runtime.config["metadata"]["allowed_subagents"] = []
monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda *, allowed_subagents: [])
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
result = _run_task_tool(
runtime=runtime,
description="blocked delegation",
prompt="do work",
subagent_type="general-purpose",
tool_call_id="tc-empty-policy",
)
message = _task_tool_message(result)
assert message.additional_kwargs[SUBAGENT_STATUS_KEY] == "failed"
assert "Available: none permitted by caller policy" in message.content
def test_task_tool_forwards_the_run_extension_snapshot_to_executor(monkeypatch):
"""The lead run binds one immutable extension snapshot; delegation must
carry that same object rather than re-reading the process singleton, which
@ -483,6 +528,7 @@ def test_task_tool_rejects_non_mapping_attributes(monkeypatch):
def test_task_tool_rejects_bash_subagent_when_host_bash_disabled(monkeypatch):
monkeypatch.setattr(task_tool_module, "get_subagent_config", lambda _: _make_subagent_config())
monkeypatch.setattr(task_tool_module, "get_available_subagent_names", lambda: ["general-purpose"])
monkeypatch.setattr(task_tool_module, "is_host_bash_allowed", lambda: False)
result = _run_task_tool(

View File

@ -500,7 +500,14 @@ def test_update_agent_round_trips_known_fields(tmp_path, patched_paths):
"""
_seed_agent(tmp_path, description="legacy")
fake_cfg = AgentConfig(name="test-agent", description="legacy", skills=["s1"], tool_groups=["g1"], model="m1")
fake_cfg = AgentConfig(
name="test-agent",
description="legacy",
skills=["s1"],
tool_groups=["g1"],
model="m1",
allowed_subagents=["planner"],
)
fake_app_config = MagicMock()
fake_app_config.get_model_config.return_value = object()
with patch("deerflow.tools.builtins.update_agent_tool.load_agent_config", return_value=fake_cfg):
@ -512,6 +519,7 @@ def test_update_agent_round_trips_known_fields(tmp_path, patched_paths):
assert cfg["skills"] == ["s1"]
assert cfg["tool_groups"] == ["g1"]
assert cfg["model"] == "m1"
assert cfg["allowed_subagents"] == ["planner"]
def test_update_agent_refuses_on_webhook_channel(tmp_path, patched_paths):

View File

@ -2092,18 +2092,20 @@ run_events:
# ============================================================================
# Agent Storage Configuration
# ============================================================================
# Where custom agent DEFINITIONS (config.yaml + SOUL.md) are stored. This is
# separate from `database` (run/thread/event data) and from agent memory.
# Where custom agent DEFINITIONS (config.yaml + SOUL.md) and deployment-level
# managed subagent definitions are stored. This is separate from `database`
# (run/thread/event data) and from agent memory.
# Restart-required (the backend is captured at Gateway lifespan startup).
#
# backend: file -- Per-user files under {base_dir}/users/{uid}/agents/ (default).
# Single-node only: an agent created on one node is invisible
# to other nodes without a shared mount.
# backend: db -- A row per agent in the shared SQL persistence layer, so every
# node sees the same agents. Requires database.backend to be
# backend: file -- Custom Agents use per-user files; managed subagents use one
# JSON file each under {base_dir}/managed-subagents/ (default).
# Node-local without a shared mount.
# backend: db -- Both definition types use the shared SQL persistence layer,
# so every node sees them. Requires database.backend to be
# 'sqlite' or 'postgres' (rejected at startup on 'memory').
#
# Switching an existing install to 'db'? Import the on-disk agents once with:
# Switching an existing install to 'db'? Import the on-disk Custom Agents and
# managed subagents once with:
# python backend/scripts/migrate_agents_to_db.py # --dry-run to preview
# The source files are left in place, so reverting to 'file' is a clean rollback.
#

View File

@ -55,7 +55,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
- `workspace/` — Chat page components (messages, artifacts, settings)
- `landing/` — Landing page sections
- `docs/` — Docs / MDX rendering components
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `integrations/` (managed third-party integration status/install clients such as Lark CLI), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `subagents/` (runtime worker catalog and administrator mutations), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `integrations/` (managed third-party integration status/install clients such as Lark CLI), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`hooks/`** — Shared React hooks
- **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge)
- **`content/`** — MDX content (blog posts, docs) rendered by the app

View File

@ -39,6 +39,10 @@
callbacks; server-issued Lark flow generations must be passed through every
config/auth completion and across switch-or-register to authorization chains
so backend cross-tab ordering remains authoritative.
Settings > Subagents reads one catalog for built-in, config, and managed
definitions. Only administrators see managed-definition mutation controls;
Custom Agent settings consume the same query and preserve stale selected names
as removable "missing" entries instead of silently widening the allowlist.
6. Components subscribe to thread state and render updates
The chat header's context-window control is intentionally persistent: while `context_usage` is unavailable, `ContextUsageBadge` renders a gauge placeholder rather than unmounting; once data arrives, the same position shows the percentage. `useThreadTokenUsage` retains placeholder data only when the response `thread_id` still matches the active route, so same-thread refetches do not flicker and cross-thread navigation never displays the previous chat's usage.

View File

@ -10,6 +10,23 @@ export const DEFAULT_MODEL_VALUE = "__default__";
export const INHERIT_VALUE = "__inherit__";
export type ThinkingSelection = "__inherit__" | "on" | "off";
export type SubagentAccessSelection = "all" | "none" | "selected";
export function allowedSubagentsToSelection(
value: string[] | null | undefined,
): SubagentAccessSelection {
if (value == null) return "all";
return value.length === 0 ? "none" : "selected";
}
export function selectionToAllowedSubagents(
selection: SubagentAccessSelection,
selectedNames: string[],
): string[] | null {
if (selection === "all") return null;
if (selection === "none") return [];
return selectedNames;
}
/**
* Map a persisted ``thinking_enabled`` (``true`` / ``false`` / ``null``) to the

View File

@ -24,14 +24,18 @@ import { useUpdateAgent } from "@/core/agents";
import type { Agent, ReasoningEffort } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useSubagents } from "@/core/subagents";
import {
allowedSubagentsToSelection,
DEFAULT_MODEL_VALUE,
INHERIT_VALUE,
MAX_AGENT_OUTPUT_TOKENS,
parseAgentModelSettingsDraft,
resolveEffectiveModel,
selectionToAllowedSubagents,
selectionToThinkingEnabled,
type SubagentAccessSelection,
thinkingEnabledToSelection,
} from "./agent-settings-dialog-helpers";
@ -56,6 +60,7 @@ export function AgentSettingsDialog({
}: AgentSettingsDialogProps) {
const { t } = useI18n();
const { models } = useModels();
const { subagents } = useSubagents();
const updateAgent = useUpdateAgent();
const [model, setModel] = useState(agent.model ?? DEFAULT_MODEL_VALUE);
@ -75,6 +80,12 @@ export function AgentSettingsDialog({
const [reasoningEffort, setReasoningEffort] = useState(
agent.reasoning_effort ?? INHERIT_VALUE,
);
const [subagentAccess, setSubagentAccess] = useState<SubagentAccessSelection>(
allowedSubagentsToSelection(agent.allowed_subagents),
);
const [selectedSubagents, setSelectedSubagents] = useState<string[]>(
agent.allowed_subagents ?? [],
);
// The resolved profile gates which controls are meaningful: thinking and
// reasoning-effort only apply when the selected model advertises support.
@ -87,6 +98,23 @@ export function AgentSettingsDialog({
const supportsThinking = selectedModel?.supports_thinking ?? false;
const supportsReasoningEffort =
selectedModel?.supports_reasoning_effort ?? false;
const selectableSubagents = useMemo(
() =>
Array.from(
new Map(
subagents
.filter((item) => item.enabled && !item.conflict)
.map((item) => [item.name, item]),
).values(),
),
[subagents],
);
const missingSubagents = useMemo(() => {
const selectableNames = new Set(
selectableSubagents.map((item) => item.name),
);
return selectedSubagents.filter((name) => !selectableNames.has(name));
}, [selectableSubagents, selectedSubagents]);
async function handleSave() {
const parsedSettings = parseAgentModelSettingsDraft({
@ -115,6 +143,10 @@ export function AgentSettingsDialog({
supportsReasoningEffort && reasoningEffort !== INHERIT_VALUE
? (reasoningEffort as ReasoningEffort)
: null,
allowed_subagents: selectionToAllowedSubagents(
subagentAccess,
selectedSubagents,
),
},
});
toast.success(t.agents.settingsSaved);
@ -244,6 +276,92 @@ export function AgentSettingsDialog({
</Select>
</div>
)}
<div className="space-y-2 border-t pt-4">
<div>
<p className="text-sm font-medium">
{t.settings.subagents.bindingTitle}
</p>
<p className="text-muted-foreground text-xs">
{t.settings.subagents.bindingDescription}
</p>
</div>
<Select
value={subagentAccess}
onValueChange={(value) =>
setSubagentAccess(value as SubagentAccessSelection)
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t.settings.subagents.allAllowed}
</SelectItem>
<SelectItem value="none">
{t.settings.subagents.noneAllowed}
</SelectItem>
<SelectItem value="selected">
{t.settings.subagents.selectedAllowed}
</SelectItem>
</SelectContent>
</Select>
{subagentAccess === "selected" && (
<div className="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
{selectableSubagents.map((item) => (
<label
key={item.name}
className="flex items-start gap-2 text-sm"
>
<input
type="checkbox"
className="mt-0.5 size-4"
checked={selectedSubagents.includes(item.name)}
onChange={(event) =>
setSelectedSubagents((current) =>
event.target.checked
? [...current, item.name]
: current.filter((name) => name !== item.name),
)
}
/>
<span>
<span className="font-medium">
{item.display_name ?? item.name}
</span>
<span className="text-muted-foreground block text-xs">
{item.description}
</span>
</span>
</label>
))}
{missingSubagents.map((name) => (
<label
key={name}
className="text-muted-foreground flex items-start gap-2 text-sm"
>
<input
type="checkbox"
className="mt-0.5 size-4"
checked
onChange={() =>
setSelectedSubagents((current) =>
current.filter((item) => item !== name),
)
}
/>
<span>
<span className="font-medium">{name}</span>
<span className="block text-xs">
{t.settings.subagents.missing}
</span>
</span>
</label>
))}
</div>
)}
</div>
</div>
<DialogFooter>

View File

@ -8,6 +8,7 @@ import {
PaletteIcon,
PlugZapIcon,
SparklesIcon,
UsersRoundIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
@ -84,6 +85,13 @@ const ToolSettingsPage = dynamic(
import("./tool-settings-page").then((module) => module.ToolSettingsPage),
{ loading: SettingsPageLoading },
);
const SubagentSettingsPage = dynamic(
() =>
import("./subagent-settings-page").then(
(module) => module.SubagentSettingsPage,
),
{ loading: SettingsPageLoading },
);
const AboutSettingsPage = dynamic(
() =>
import("./about-settings-page").then((module) => module.AboutSettingsPage),
@ -97,6 +105,7 @@ export type SettingsSection =
| "integrations"
| "memory"
| "tools"
| "subagents"
| "skills"
| "notification"
| "about";
@ -152,6 +161,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
icon: BrainIcon,
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{
id: "subagents",
label: t.settings.sections.subagents,
icon: UsersRoundIcon,
},
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
{ id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
@ -162,6 +176,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.integrations,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.subagents,
t.settings.sections.skills,
t.settings.sections.notification,
t.settings.sections.about,
@ -213,6 +228,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "subagents" && <SubagentSettingsPage />}
{activeSection === "skills" && (
<SkillSettingsPage
onClose={() => props.onOpenChange?.(false)}

View File

@ -0,0 +1,32 @@
export type OptionalNameListMode = "all" | "none" | "selected";
export function optionalNameListToDraft(value: string[] | null): {
mode: OptionalNameListMode;
text: string;
} {
if (value == null) return { mode: "all", text: "" };
if (value.length === 0) return { mode: "none", text: "" };
return { mode: "selected", text: value.join(", ") };
}
export function optionalNameListFromDraft(
mode: OptionalNameListMode,
text: string,
): string[] | null {
if (mode === "all") return null;
if (mode === "none") return [];
const values = text
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return Array.from(new Set(values));
}
export function positiveInteger(value: string): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
export function isValidManagedSubagentName(value: string): boolean {
return /^[A-Za-z0-9-]+$/.test(value.trim());
}

View File

@ -0,0 +1,498 @@
"use client";
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import {
useCreateManagedSubagent,
useDeleteManagedSubagent,
useSubagents,
useUpdateManagedSubagent,
} from "@/core/subagents";
import type { Subagent } from "@/core/subagents";
import { SettingsSection } from "./settings-section";
import {
isValidManagedSubagentName,
optionalNameListFromDraft,
optionalNameListToDraft,
positiveInteger,
type OptionalNameListMode,
} from "./subagent-settings-helpers";
type Draft = {
name: string;
displayName: string;
description: string;
systemPrompt: string;
model: string;
toolsMode: OptionalNameListMode;
tools: string;
skillsMode: OptionalNameListMode;
skills: string;
maxTurns: string;
timeoutSeconds: string;
};
const EMPTY_DRAFT: Draft = {
name: "",
displayName: "",
description: "",
systemPrompt: "",
model: "inherit",
toolsMode: "all",
tools: "",
skillsMode: "all",
skills: "",
maxTurns: "50",
timeoutSeconds: "900",
};
function formatOverrideValue(value: unknown): string {
if (Array.isArray(value)) return value.join(", ");
if (value && typeof value === "object") return JSON.stringify(value);
return String(value);
}
function draftFrom(subagent: Subagent): Draft {
const tools = optionalNameListToDraft(subagent.tools);
const skills = optionalNameListToDraft(subagent.skills);
return {
name: subagent.name,
displayName: subagent.display_name ?? "",
description: subagent.description,
systemPrompt: subagent.system_prompt ?? "",
model: subagent.model,
toolsMode: tools.mode,
tools: tools.text,
skillsMode: skills.mode,
skills: skills.text,
maxTurns: String(subagent.max_turns),
timeoutSeconds: String(subagent.timeout_seconds),
};
}
export function SubagentSettingsPage() {
const { t } = useI18n();
const { user } = useAuth();
const isAdmin = user?.system_role === "admin";
const { subagents, isLoading, error } = useSubagents();
const update = useUpdateManagedSubagent();
const remove = useDeleteManagedSubagent();
const [editing, setEditing] = useState<Subagent | "new" | null>(null);
async function setEnabled(subagent: Subagent, enabled: boolean) {
try {
await update.mutateAsync({ name: subagent.name, request: { enabled } });
toast.success(t.settings.subagents.saved);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function deleteSubagent(subagent: Subagent) {
if (!window.confirm(t.settings.subagents.deleteConfirm)) return;
try {
await remove.mutateAsync(subagent.name);
toast.success(t.settings.subagents.deleted);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<SettingsSection
title={t.settings.subagents.title}
description={t.settings.subagents.description}
>
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t.settings.subagents.executionNote}
</p>
<div className="flex items-center justify-between gap-4">
{!isAdmin && (
<p className="text-muted-foreground text-sm">
{t.settings.subagents.adminNote}
</p>
)}
{isAdmin && (
<Button
size="sm"
className="ml-auto"
onClick={() => setEditing("new")}
>
<PlusIcon className="size-4" />
{t.settings.subagents.create}
</Button>
)}
</div>
{isLoading ? (
<p className="text-muted-foreground text-sm">{t.common.loading}</p>
) : error ? (
<p className="text-destructive text-sm">{error.message}</p>
) : subagents.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t.settings.subagents.empty}
</p>
) : (
<div className="space-y-3">
{subagents.map((subagent) => (
<Item
variant="outline"
key={`${subagent.source}-${subagent.name}`}
>
<ItemContent>
<ItemTitle className="flex flex-wrap items-center gap-2">
<span>{subagent.display_name ?? subagent.name}</span>
<Badge variant="outline">
{subagent.source === "builtin"
? t.settings.subagents.sourceBuiltin
: subagent.source === "config"
? t.settings.subagents.sourceConfig
: t.settings.subagents.sourceManaged}
</Badge>
{subagent.conflict && (
<Badge variant="destructive">
{t.settings.subagents.conflict}
</Badge>
)}
</ItemTitle>
<ItemDescription>{subagent.description}</ItemDescription>
{Object.keys(subagent.config_overrides).length > 0 && (
<p className="text-muted-foreground text-xs">
{t.settings.subagents.overridden}:{" "}
{Object.entries(subagent.config_overrides)
.map(
([field, value]) =>
`${field}=${formatOverrideValue(value)}`,
)
.join("; ")}
</p>
)}
</ItemContent>
<ItemActions className="gap-1">
{isAdmin && subagent.editable && (
<>
<Switch
checked={subagent.enabled}
disabled={update.isPending || subagent.conflict}
onCheckedChange={(enabled) =>
void setEnabled(subagent, enabled)
}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setEditing(subagent)}
>
<PencilIcon className="size-4" />
<span className="sr-only">{t.common.edit}</span>
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => void deleteSubagent(subagent)}
>
<Trash2Icon className="size-4" />
<span className="sr-only">{t.common.delete}</span>
</Button>
</>
)}
</ItemActions>
</Item>
))}
</div>
)}
</div>
<SubagentEditor
value={editing}
onOpenChange={(open) => !open && setEditing(null)}
/>
</SettingsSection>
);
}
function SubagentEditor({
value,
onOpenChange,
}: {
value: Subagent | "new" | null;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { models } = useModels();
const create = useCreateManagedSubagent();
const update = useUpdateManagedSubagent();
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
useEffect(() => {
setDraft(value && value !== "new" ? draftFrom(value) : EMPTY_DRAFT);
}, [value]);
const isNew = value === "new";
const pending = create.isPending || update.isPending;
function set<K extends keyof Draft>(key: K, next: Draft[K]) {
setDraft((current) => ({ ...current, [key]: next }));
}
async function save() {
const maxTurns = positiveInteger(draft.maxTurns);
const timeoutSeconds = positiveInteger(draft.timeoutSeconds);
if (
maxTurns === null ||
timeoutSeconds === null ||
!isValidManagedSubagentName(draft.name)
)
return;
const payload = {
display_name: draft.displayName.trim() || null,
description: draft.description.trim(),
system_prompt: draft.systemPrompt.trim(),
model: draft.model,
tools: optionalNameListFromDraft(draft.toolsMode, draft.tools),
skills: optionalNameListFromDraft(draft.skillsMode, draft.skills),
max_turns: maxTurns,
timeout_seconds: timeoutSeconds,
};
try {
if (isNew) {
await create.mutateAsync({ name: draft.name.trim(), ...payload });
toast.success(t.settings.subagents.created);
} else if (value) {
await update.mutateAsync({ name: value.name, request: payload });
toast.success(t.settings.subagents.saved);
}
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<Dialog open={value !== null} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{isNew
? t.settings.subagents.createTitle
: t.settings.subagents.editTitle}
</DialogTitle>
<DialogDescription>
{t.settings.subagents.description}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-1 sm:grid-cols-2">
<Field label={t.settings.subagents.name}>
<Input
value={draft.name}
disabled={!isNew}
onChange={(event) => set("name", event.target.value)}
/>
{isNew && (
<p className="text-muted-foreground text-xs">
{t.settings.subagents.nameHint}
</p>
)}
</Field>
<Field label={t.settings.subagents.displayName}>
<Input
value={draft.displayName}
onChange={(event) => set("displayName", event.target.value)}
/>
</Field>
<Field
className="sm:col-span-2"
label={t.settings.subagents.descriptionLabel}
>
<Textarea
value={draft.description}
onChange={(event) => set("description", event.target.value)}
/>
</Field>
<Field
className="sm:col-span-2"
label={t.settings.subagents.systemPrompt}
>
<Textarea
className="min-h-32"
value={draft.systemPrompt}
onChange={(event) => set("systemPrompt", event.target.value)}
/>
</Field>
<Field label={t.settings.subagents.model}>
<Select
value={draft.model}
onValueChange={(next) => set("model", next)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">
{t.settings.subagents.inheritModel}
</SelectItem>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label={t.settings.subagents.tools}>
<OptionalNameListField
mode={draft.toolsMode}
text={draft.tools}
onModeChange={(mode) => set("toolsMode", mode)}
onTextChange={(text) => set("tools", text)}
/>
</Field>
<Field label={t.settings.subagents.skills}>
<OptionalNameListField
mode={draft.skillsMode}
text={draft.skills}
onModeChange={(mode) => set("skillsMode", mode)}
onTextChange={(text) => set("skills", text)}
/>
</Field>
<Field label={t.settings.subagents.maxTurns}>
<Input
type="number"
min={1}
step={1}
value={draft.maxTurns}
onChange={(event) => set("maxTurns", event.target.value)}
/>
</Field>
<Field label={t.settings.subagents.timeout}>
<Input
type="number"
min={1}
step={1}
value={draft.timeoutSeconds}
onChange={(event) => set("timeoutSeconds", event.target.value)}
/>
</Field>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void save()}
disabled={
pending ||
!isValidManagedSubagentName(draft.name) ||
!draft.description.trim() ||
!draft.systemPrompt.trim() ||
positiveInteger(draft.maxTurns) === null ||
positiveInteger(draft.timeoutSeconds) === null
}
>
{pending ? t.common.loading : t.common.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function OptionalNameListField({
mode,
text,
onModeChange,
onTextChange,
}: {
mode: OptionalNameListMode;
text: string;
onModeChange: (mode: OptionalNameListMode) => void;
onTextChange: (text: string) => void;
}) {
const { t } = useI18n();
return (
<div className="space-y-2">
<Select
value={mode}
onValueChange={(value) => onModeChange(value as OptionalNameListMode)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t.settings.subagents.listModeAll}
</SelectItem>
<SelectItem value="none">
{t.settings.subagents.listModeNone}
</SelectItem>
<SelectItem value="selected">
{t.settings.subagents.listModeSelected}
</SelectItem>
</SelectContent>
</Select>
{mode === "selected" && (
<Input
value={text}
placeholder={t.settings.subagents.listNamesPlaceholder}
onChange={(event) => onTextChange(event.target.value)}
/>
)}
</div>
);
}
function Field({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<label className={`space-y-1.5 ${className ?? ""}`}>
<span className="text-sm font-medium">{label}</span>
{children}
</label>
);
}

View File

@ -16,6 +16,7 @@ const SETTINGS_SECTIONS = new Set<SettingsSection>([
"integrations",
"memory",
"tools",
"subagents",
"skills",
"notification",
"about",

View File

@ -47,9 +47,9 @@ The Lead Agent delegates work to a subagent using the built-in `task` tool:
```
task(
agent="general-purpose",
task="Research the top 5 competitors of Acme Corp and summarize their pricing",
context="Focus on B2B SaaS pricing models"
description="research competitors",
prompt="Research the top 5 competitors of Acme Corp and summarize their B2B SaaS pricing",
subagent_type="general-purpose"
)
```
@ -97,6 +97,20 @@ subagents:
Per-agent overrides take priority over the global `timeout_seconds`, `max_turns`, and `token_budget` settings.
## Managed subagents and Custom Agent access
Administrators can add reusable worker definitions from **Settings → Subagents**. A managed subagent defines its delegation description, system prompt, model, tools, skills, maximum turns, timeout, and enabled state. Built-in and `config.yaml` definitions appear in the same catalog as read-only entries.
The default Lead Agent can see every enabled runtime subagent. Each page-created Custom Agent can narrow that catalog in its settings:
- **All enabled subagents**: no additional restriction.
- **No subagents**: delegation is disabled even if the request enables it.
- **Selected subagents**: only the selected names are shown in the prompt and accepted by the `task` tool.
The allowlist is copied into run metadata when a run starts and is enforced again by the `task` tool, so a client cannot bypass it by naming a hidden subagent directly. Runtime precedence is **built-in → `config.yaml` → managed**. If an operator later adds a conflicting `config.yaml` name, the managed entry remains visible in Settings with a conflict warning but is excluded from runtime discovery. Explicit `subagents.agents.<name>` overrides continue to take priority and are shown in the Settings catalog.
Managed definitions use the same backend selection as Custom Agents: `agent_storage.backend: file` stores one atomic JSON file per definition under `DEER_FLOW_HOME/managed-subagents/`; `agent_storage.backend: db` stores them in the shared application database for multi-instance deployments. These definitions are deployment-wide in this version, not user-scoped.
## Delegation limits
The `SubagentLimitMiddleware` controls how many subagents the Lead Agent can invoke in parallel in a single turn and how many total subagent delegations one lead-agent run may launch.
@ -150,10 +164,6 @@ The Lead Agent invokes ACP agents through the `invoke_acp_agent` built-in tool.
listed above or a compatible ACP wrapper.
</Callout>
## Custom agents as subagents
Custom agents created through the DeerFlow App UI can also be invoked as subagents using the `task` tool. When you specify `agent="my-custom-agent"`, the runtime loads that agent's configuration (skills, tool groups, model) and runs it as a subagent for the delegated task.
<Cards num={2}>
<Cards.Card title="Sandbox" href="/docs/harness/sandbox" />
<Cards.Card title="MCP Integration" href="/docs/harness/mcp" />

View File

@ -46,9 +46,9 @@ Lead Agent 使用内置 `task` 工具将工作委派给子 Agent
```
task(
agent="general-purpose",
task="研究 Acme Corp 的前 5 个竞争对手并总结其定价",
context="专注于 B2B SaaS 定价模型"
description="研究竞争对手",
prompt="研究 Acme Corp 的前 5 个竞争对手并总结其 B2B SaaS 定价",
subagent_type="general-purpose"
)
```
@ -95,6 +95,20 @@ subagents:
按 Agent 覆盖优先于全局 `timeout_seconds`、`max_turns` 和 `token_budget` 设置。
## 设置页管理与 Custom Agent 调用范围
管理员可以在**设置 → 子智能体**中添加可复用的工作智能体。每个设置页管理的子智能体可以定义派遣说明、系统提示词、模型、工具、技能、最大轮次、超时和启用状态。内置项和 `config.yaml` 项也会显示在同一个目录中,但保持只读。
默认 Lead Agent 可以看到全部已启用的运行时子智能体。页面创建的每个 Custom Agent 可以在自身设置中进一步收窄调用范围:
- **全部已启用子智能体**:不增加额外限制。
- **不允许使用子智能体**:即使请求开启了委派,也不能重新打开。
- **仅允许选中的子智能体**:提示词只展示所选名称,`task` 工具也只接受这些名称。
运行开始时,允许列表会作为快照写入 run metadata`task` 工具会再次进行服务端校验,因此客户端不能通过直接填写隐藏名称绕过限制。运行时优先级为**内置 → `config.yaml` → 设置页管理**。如果运维之后在 `config.yaml` 中加入同名项,设置页管理的条目仍会保留并显示冲突提示,但会从运行时目录中排除。`subagents.agents.<name>` 的显式覆盖仍保持最高优先级,并会在设置目录中提示。
设置页管理的定义跟随 Custom Agent 现有的存储后端:`agent_storage.backend: file` 会在 `DEER_FLOW_HOME/managed-subagents/` 下为每个定义写一个原子 JSON 文件;`agent_storage.backend: db` 会写入共享应用数据库,供多实例部署共同读取。当前版本是部署级全局数据,不按用户隔离。
## 委派限制
`SubagentLimitMiddleware` 控制 Lead Agent 在单次轮次中可以并行调用多少个子 Agent也控制一次 Lead Agent run 内最多可以启动多少次子 Agent 委派。

View File

@ -11,6 +11,7 @@ export interface Agent {
model: string | null;
tool_groups: string[] | null;
skills: string[] | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
reasoning_effort?: ReasoningEffort | null;
@ -23,6 +24,7 @@ export interface CreateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
reasoning_effort?: ReasoningEffort | null;
@ -34,6 +36,7 @@ export interface UpdateAgentRequest {
model?: string | null;
tool_groups?: string[] | null;
skills?: string[] | null;
allowed_subagents?: string[] | null;
model_settings?: AgentModelSettings | null;
thinking_enabled?: boolean | null;
reasoning_effort?: ReasoningEffort | null;

View File

@ -800,6 +800,7 @@ export const enUS: Translations = {
integrations: "Integrations",
memory: "Memory",
tools: "Tools",
subagents: "Subagents",
skills: "Skills",
notification: "Notification",
about: "About",
@ -902,6 +903,51 @@ export const enUS: Translations = {
adminRequired: "Admin privileges are required to manage MCP tools.",
empty: "No MCP tools configured.",
},
subagents: {
title: "Subagents",
description:
"Reusable workers that the Lead Agent and permitted Custom Agents can delegate bounded tasks to.",
executionNote:
"Each invocation starts a fresh temporary context with no persistent chat or memory and cannot ask the user follow-up questions. A system prompt changes behavior; tools and skills grant actual capabilities.",
adminNote:
"You can view the catalog. Only administrators can add, edit, enable, or delete subagents.",
create: "Add subagent",
empty: "No subagents are available.",
sourceBuiltin: "Built-in",
sourceConfig: "config.yaml",
sourceManaged: "Managed",
conflict: "Name conflict — excluded from runtime",
overridden: "Some runtime values are overridden by config.yaml",
createTitle: "Add managed subagent",
editTitle: "Edit managed subagent",
name: "Name",
nameHint: "Use letters, numbers, and hyphens only.",
displayName: "Display name",
descriptionLabel: "Delegation description",
systemPrompt: "System prompt",
model: "Model",
inheritModel: "Inherit from caller",
tools: "Allowed tools (comma-separated)",
skills: "Skills (comma-separated)",
listModeAll: "Inherit all available",
listModeNone: "Allow none",
listModeSelected: "Allow selected names",
listNamesPlaceholder: "Comma-separated names",
maxTurns: "Maximum turns",
timeout: "Timeout (seconds)",
created: "Subagent created",
saved: "Subagent saved",
deleted: "Subagent deleted",
deleteConfirm:
"Delete this managed subagent? Custom Agents may keep referencing its name, and recreating the same name will reconnect those bindings. This cannot be undone.",
bindingTitle: "Subagent access",
bindingDescription:
"Choose which subagents this Custom Agent may invoke. This is enforced by the server.",
allAllowed: "All enabled subagents",
noneAllowed: "No subagents",
selectedAllowed: "Selected subagents",
missing: "Missing or unavailable; deselect to remove",
},
channels: {
title: "Channels",
description:

View File

@ -671,6 +671,7 @@ export interface Translations {
integrations: string;
memory: string;
tools: string;
subagents: string;
skills: string;
notification: string;
about: string;
@ -766,6 +767,46 @@ export interface Translations {
adminRequired: string;
empty: string;
};
subagents: {
title: string;
description: string;
executionNote: string;
adminNote: string;
create: string;
empty: string;
sourceBuiltin: string;
sourceConfig: string;
sourceManaged: string;
conflict: string;
overridden: string;
createTitle: string;
editTitle: string;
name: string;
nameHint: string;
displayName: string;
descriptionLabel: string;
systemPrompt: string;
model: string;
inheritModel: string;
tools: string;
skills: string;
listModeAll: string;
listModeNone: string;
listModeSelected: string;
listNamesPlaceholder: string;
maxTurns: string;
timeout: string;
created: string;
saved: string;
deleted: string;
deleteConfirm: string;
bindingTitle: string;
bindingDescription: string;
allAllowed: string;
noneAllowed: string;
selectedAllowed: string;
missing: string;
};
channels: {
title: string;
description: string;

View File

@ -767,6 +767,7 @@ export const zhCN: Translations = {
integrations: "集成",
memory: "记忆",
tools: "工具",
subagents: "子智能体",
skills: "技能",
notification: "通知",
about: "关于",
@ -866,6 +867,51 @@ export const zhCN: Translations = {
adminRequired: "需要管理员权限才能管理 MCP 工具。",
empty: "暂无 MCP 工具。",
},
subagents: {
title: "子智能体",
description:
"可由 Lead Agent 和已授权 Custom Agent 派遣、执行边界明确任务的复用型工作智能体。",
executionNote:
"每次调用都使用全新的临时上下文,没有独立持久会话或记忆,也不能向用户追问。系统提示词只改变工作方式;工具和技能才提供实际能力。",
adminNote:
"你可以查看目录;只有管理员可以添加、编辑、启停或删除子智能体。",
create: "添加子智能体",
empty: "暂无可用子智能体。",
sourceBuiltin: "内置",
sourceConfig: "config.yaml",
sourceManaged: "设置页管理",
conflict: "名称冲突,已从运行时排除",
overridden: "部分运行参数已被 config.yaml 覆盖",
createTitle: "添加子智能体",
editTitle: "编辑子智能体",
name: "名称",
nameHint: "仅可使用字母、数字和连字符。",
displayName: "显示名称",
descriptionLabel: "派遣说明",
systemPrompt: "系统提示词",
model: "模型",
inheritModel: "继承调用者模型",
tools: "允许的工具(逗号分隔)",
skills: "技能(逗号分隔)",
listModeAll: "继承全部可用项",
listModeNone: "全部禁用",
listModeSelected: "仅允许指定名称",
listNamesPlaceholder: "多个名称用逗号分隔",
maxTurns: "最大轮次",
timeout: "超时时间(秒)",
created: "子智能体已创建",
saved: "子智能体已保存",
deleted: "子智能体已删除",
deleteConfirm:
"确定删除这个子智能体吗Custom Agent 可能仍保留对该名称的引用;以同名重建后,这些绑定会重新生效。此操作无法撤销。",
bindingTitle: "子智能体权限",
bindingDescription:
"选择这个 Custom Agent 可以派遣哪些子智能体;服务端会强制执行该范围。",
allAllowed: "全部已启用子智能体",
noneAllowed: "不允许使用子智能体",
selectedAllowed: "仅允许选中的子智能体",
missing: "已缺失或不可用;取消勾选后移除",
},
channels: {
title: "渠道",
description: "连接可在浏览器外向 DeerFlow 发送消息的即时通讯账号。",

View File

@ -0,0 +1,71 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type {
CreateManagedSubagentRequest,
Subagent,
UpdateManagedSubagentRequest,
} from "./types";
async function errorDetail(res: Response, fallback: string): Promise<string> {
const body = (await res.json().catch(() => ({}))) as { detail?: unknown };
if (typeof body.detail === "string") return body.detail;
if (Array.isArray(body.detail)) {
const messages = body.detail
.map((item) =>
item && typeof item === "object" && "msg" in item
? String(item.msg)
: null,
)
.filter((message): message is string => message !== null);
if (messages.length > 0) return messages.join("; ");
}
return fallback;
}
export async function listSubagents(): Promise<Subagent[]> {
const res = await fetch(`${getBackendBaseURL()}/api/subagents`);
if (!res.ok)
throw new Error(await errorDetail(res, "Failed to load subagents"));
const body = (await res.json()) as { subagents: Subagent[] };
return body.subagents;
}
export async function createManagedSubagent(
request: CreateManagedSubagentRequest,
): Promise<Subagent> {
const res = await fetch(`${getBackendBaseURL()}/api/subagents`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (!res.ok)
throw new Error(await errorDetail(res, "Failed to create subagent"));
return res.json() as Promise<Subagent>;
}
export async function updateManagedSubagent(
name: string,
request: UpdateManagedSubagentRequest,
): Promise<Subagent> {
const res = await fetch(
`${getBackendBaseURL()}/api/subagents/${encodeURIComponent(name)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
},
);
if (!res.ok)
throw new Error(await errorDetail(res, "Failed to update subagent"));
return res.json() as Promise<Subagent>;
}
export async function deleteManagedSubagent(name: string): Promise<void> {
const res = await fetch(
`${getBackendBaseURL()}/api/subagents/${encodeURIComponent(name)}`,
{ method: "DELETE" },
);
if (!res.ok)
throw new Error(await errorDetail(res, "Failed to delete subagent"));
}

View File

@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createManagedSubagent,
deleteManagedSubagent,
listSubagents,
updateManagedSubagent,
} from "./api";
import type {
CreateManagedSubagentRequest,
UpdateManagedSubagentRequest,
} from "./types";
export const SUBAGENTS_QUERY_KEY = ["subagents"] as const;
export function useSubagents() {
const query = useQuery({
queryKey: SUBAGENTS_QUERY_KEY,
queryFn: listSubagents,
});
return {
subagents: query.data ?? [],
isLoading: query.isLoading,
error: query.error,
};
}
export function useCreateManagedSubagent() {
const client = useQueryClient();
return useMutation({
mutationFn: (request: CreateManagedSubagentRequest) =>
createManagedSubagent(request),
onSuccess: () =>
client.invalidateQueries({ queryKey: SUBAGENTS_QUERY_KEY }),
});
}
export function useUpdateManagedSubagent() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
name,
request,
}: {
name: string;
request: UpdateManagedSubagentRequest;
}) => updateManagedSubagent(name, request),
onSuccess: () =>
client.invalidateQueries({ queryKey: SUBAGENTS_QUERY_KEY }),
});
}
export function useDeleteManagedSubagent() {
const client = useQueryClient();
return useMutation({
mutationFn: deleteManagedSubagent,
onSuccess: () =>
client.invalidateQueries({ queryKey: SUBAGENTS_QUERY_KEY }),
});
}

View File

@ -0,0 +1,2 @@
export * from "./hooks";
export type * from "./types";

View File

@ -0,0 +1,37 @@
export type SubagentSource = "builtin" | "config" | "managed";
export interface Subagent {
name: string;
display_name: string | null;
description: string;
system_prompt: string | null;
tools: string[] | null;
disallowed_tools: string[] | null;
skills: string[] | null;
model: string;
max_turns: number;
timeout_seconds: number;
enabled: boolean;
source: SubagentSource;
editable: boolean;
conflict: boolean;
config_overrides: Record<string, unknown>;
}
export interface CreateManagedSubagentRequest {
name: string;
display_name?: string | null;
description: string;
system_prompt: string;
tools?: string[] | null;
disallowed_tools?: string[] | null;
skills?: string[] | null;
model?: string;
max_turns?: number;
timeout_seconds?: number;
enabled?: boolean;
}
export type UpdateManagedSubagentRequest = Partial<
Omit<CreateManagedSubagentRequest, "name">
>;

View File

@ -1,11 +1,13 @@
import { describe, expect, it } from "@rstest/core";
import {
allowedSubagentsToSelection,
DEFAULT_MODEL_VALUE,
INHERIT_VALUE,
MAX_AGENT_OUTPUT_TOKENS,
parseAgentModelSettingsDraft,
resolveEffectiveModel,
selectionToAllowedSubagents,
selectionToThinkingEnabled,
thinkingEnabledToSelection,
} from "@/components/workspace/agents/agent-settings-dialog-helpers";
@ -69,6 +71,19 @@ describe("thinkingEnabledToSelection", () => {
});
});
describe("Custom Agent subagent access", () => {
it("round-trips all, none, and selected without collapsing null and []", () => {
expect(allowedSubagentsToSelection(null)).toBe("all");
expect(allowedSubagentsToSelection([])).toBe("none");
expect(allowedSubagentsToSelection(["planner"])).toBe("selected");
expect(selectionToAllowedSubagents("all", ["planner"])).toBeNull();
expect(selectionToAllowedSubagents("none", ["planner"])).toEqual([]);
expect(selectionToAllowedSubagents("selected", ["planner"])).toEqual([
"planner",
]);
});
});
describe("selectionToThinkingEnabled", () => {
it("round-trips the tri-state back to the persisted value", () => {
expect(selectionToThinkingEnabled(INHERIT_VALUE)).toBeNull();

View File

@ -23,7 +23,7 @@ describe("interaction-only bundle boundaries", () => {
const dialog = read(
"src/components/workspace/settings/settings-dialog.tsx",
);
expect(dialog.match(/dynamic\(/g)).toHaveLength(9);
expect(dialog.match(/dynamic\(/g)).toHaveLength(10);
expect(dialog).not.toMatch(
/import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//,
);

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from "@rstest/core";
import {
isValidManagedSubagentName,
optionalNameListFromDraft,
optionalNameListToDraft,
positiveInteger,
} from "@/components/workspace/settings/subagent-settings-helpers";
describe("managed Subagent optional name lists", () => {
it("round-trips inherit-all, deny-all, and selected as distinct states", () => {
expect(optionalNameListToDraft(null)).toEqual({ mode: "all", text: "" });
expect(optionalNameListToDraft([])).toEqual({ mode: "none", text: "" });
expect(optionalNameListToDraft(["read_file", "web_search"])).toEqual({
mode: "selected",
text: "read_file, web_search",
});
expect(optionalNameListFromDraft("all", "ignored")).toBeNull();
expect(optionalNameListFromDraft("none", "ignored")).toEqual([]);
expect(
optionalNameListFromDraft(
"selected",
" read_file, web_search, read_file ",
),
).toEqual(["read_file", "web_search"]);
});
it("keeps an empty selected list as [] instead of widening it to null", () => {
expect(optionalNameListFromDraft("selected", " ")).toEqual([]);
});
});
describe("managed Subagent field validation", () => {
it("matches the backend name and positive-integer boundaries", () => {
expect(isValidManagedSubagentName("creative-planner")).toBe(true);
expect(isValidManagedSubagentName("../planner")).toBe(false);
expect(isValidManagedSubagentName("creative_planner")).toBe(false);
expect(positiveInteger("1")).toBe(1);
expect(positiveInteger("1.5")).toBeNull();
expect(positiveInteger("")).toBeNull();
});
});

View File

@ -184,4 +184,24 @@ describe("updateAgent", () => {
reasoning_effort: "high",
});
});
test("serializes an explicit null subagent allowlist as unrestricted", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
name: "delegator",
description: "",
model: null,
tool_groups: null,
skills: null,
allowed_subagents: null,
}),
);
await updateAgent("delegator", { allowed_subagents: null });
const [, init] = mockedFetch.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
allowed_subagents: null,
});
});
});

View File

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, test, rs } from "@rstest/core";
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
import { fetch as fetcher } from "@/core/api/fetcher";
import {
createManagedSubagent,
listSubagents,
updateManagedSubagent,
} from "@/core/subagents/api";
const mockedFetch = rs.mocked(fetcher);
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
mockedFetch.mockReset();
});
describe("managed subagent API", () => {
test("lists the mixed-source catalog", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
subagents: [
{
name: "general-purpose",
description: "General worker",
source: "builtin",
enabled: true,
},
],
}),
);
await expect(listSubagents()).resolves.toHaveLength(1);
});
test("creates and updates managed definitions through admin endpoints", async () => {
mockedFetch
.mockResolvedValueOnce(
jsonResponse(201, {
name: "planner",
description: "Plans",
source: "managed",
}),
)
.mockResolvedValueOnce(
jsonResponse(200, {
name: "planner",
description: "Plans",
source: "managed",
enabled: false,
}),
);
await createManagedSubagent({
name: "planner",
description: "Plans",
system_prompt: "You plan.",
});
await updateManagedSubagent("planner", { enabled: false });
expect(mockedFetch.mock.calls[0]?.[1]?.method).toBe("POST");
expect(mockedFetch.mock.calls[1]?.[1]?.method).toBe("PUT");
expect(JSON.parse(mockedFetch.mock.calls[1]?.[1]?.body as string)).toEqual({
enabled: false,
});
});
test("surfaces backend conflict details", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(409, { detail: "Subagent name is reserved" }),
);
await expect(
createManagedSubagent({
name: "general-purpose",
description: "Duplicate",
system_prompt: "Duplicate",
}),
).rejects.toThrow("Subagent name is reserved");
});
test("formats FastAPI validation detail arrays", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(422, {
detail: [{ loc: ["body", "name"], msg: "String should match pattern" }],
}),
);
await expect(
createManagedSubagent({
name: "invalid_name",
description: "Invalid",
system_prompt: "Invalid",
}),
).rejects.toThrow("String should match pattern");
});
});