mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
9232e1e6a9
commit
1aa813ddb3
@ -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
|
||||
|
||||
@ -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 与文件系统
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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).
|
||||
|
||||
238
backend/app/gateway/routers/subagents.py
Normal file
238
backend/app/gateway/routers/subagents.py
Normal 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")
|
||||
@ -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`
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'."
|
||||
),
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
@ -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."""
|
||||
@ -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)
|
||||
@ -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),
|
||||
)
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)])
|
||||
|
||||
187
backend/tests/test_managed_subagent_registry.py
Normal file
187
backend/tests/test_managed_subagent_registry.py
Normal 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
|
||||
139
backend/tests/test_managed_subagent_store.py
Normal file
139
backend/tests/test_managed_subagent_store.py
Normal 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)
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
217
backend/tests/test_subagents_router.py
Normal file
217
backend/tests/test_subagents_router.py
Normal 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
|
||||
@ -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(
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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.
|
||||
#
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ const SETTINGS_SECTIONS = new Set<SettingsSection>([
|
||||
"integrations",
|
||||
"memory",
|
||||
"tools",
|
||||
"subagents",
|
||||
"skills",
|
||||
"notification",
|
||||
"about",
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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 委派。
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 发送消息的即时通讯账号。",
|
||||
|
||||
71
frontend/src/core/subagents/api.ts
Normal file
71
frontend/src/core/subagents/api.ts
Normal 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"));
|
||||
}
|
||||
60
frontend/src/core/subagents/hooks.ts
Normal file
60
frontend/src/core/subagents/hooks.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
2
frontend/src/core/subagents/index.ts
Normal file
2
frontend/src/core/subagents/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./hooks";
|
||||
export type * from "./types";
|
||||
37
frontend/src/core/subagents/types.ts
Normal file
37
frontend/src/core/subagents/types.ts
Normal 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">
|
||||
>;
|
||||
@ -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();
|
||||
|
||||
@ -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\//,
|
||||
);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
102
frontend/tests/unit/core/subagents/api.test.ts
Normal file
102
frontend/tests/unit/core/subagents/api.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user