Totoro 8d11aeb97b
feat(subagents): configure thinking and reasoning effort for native workers (#7668)
* feat(subagents): add thinking_enabled and reasoning_effort config for sub-agents (#4875)

(cherry picked from commit b1083728b23504cb0f1d1a463f8db70c5ec1c5a7)

* fix: resolve CI failures for subagent thinking config PR

- Fix UnboundLocalError in task_tool.py: initialize parent_thinking_enabled
  and parent_reasoning_effort before the if runtime block
- Remove unused imports from test_subagent_thinking_config.py
  (SimpleNamespace, pytest, resolve_subagent_model_name)
- Fix import sorting in subagents_config.py and test file
- Add reasoning_effort: None to executor test assertion

(cherry picked from commit 187851e307b871937e48bb67ae1b34b8b69bf92d)

* fix: address review comments - remove parent inheritance, supports_thinking clamp, Literal type

- Remove parent_thinking_enabled/parent_reasoning_effort from metadata
  extraction in task_tool.py (backward compatible: subagents only think
  when explicitly configured)
- Add supports_thinking clamp in _create_agent to prevent cross-model
  mismatch errors
- Change SubagentConfig.reasoning_effort to Literal type
- Fix sandbox_state assignment that was accidentally removed
- Fix syntax errors in subagents_config.py (missing closing parens)
- Update all docstrings/comments from "inherit" to "no override"
- Simplify test helper and remove parent inheritance tests

(cherry picked from commit 74f9c659ef4a98bd1756109929ab0748fab82317)

* feat(subagents): adapt reasoning settings to current native workers

* fix(subagents): retain model context on reasoning rejection

* fix(subagents): preserve model-factory reasoning diagnostics

---------

Co-authored-by: Neon549 <neon549@users.noreply.github.com>
Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
2026-10-29 22:26:12 +08:00

250 lines
10 KiB
Python

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