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>
This commit is contained in:
Totoro 2026-10-29 22:26:12 +08:00 committed by GitHub
parent 30aa601f6a
commit 8d11aeb97b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 291 additions and 18 deletions

View File

@ -2816,6 +2816,8 @@ The factory still does not load YAML or create SQL infrastructure: the caller su
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.
Native workers support per-subagent `thinking_enabled` and `reasoning_effort` through YAML configuration and the managed-subagent API. Unconfigured workers keep the existing thinking-off request; the selected model's required-thinking policy, supported effort values and aliases still apply. These settings do not inherit the lead agent's thinking preferences.
For example, independent read-only research can run concurrently when the wall-clock savings outweigh duplicated discovery and synthesis cost, while a repository refactor with shared files and sequential test feedback remains with the lead agent. When `max_concurrent_subagents` is `1`, parallel and multi-batch routing guidance is disabled; delegation remains available only for material specialist or context-isolation benefit.
### Sandbox & File System

View File

@ -35,6 +35,8 @@ class SubagentResponse(BaseModel):
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
@ -57,6 +59,8 @@ class ManagedSubagentCreateRequest(BaseModel):
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
@ -70,6 +74,8 @@ class ManagedSubagentUpdateRequest(BaseModel):
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
@ -94,7 +100,7 @@ def _explicit_overrides(name: str, app_config) -> dict[str, Any]:
if override is None:
return {}
result: dict[str, Any] = {}
for field in ("timeout_seconds", "max_turns", "model", "skills"):
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)
@ -119,6 +125,8 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
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",
@ -136,6 +144,8 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
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",

View File

@ -106,6 +106,14 @@ class SubagentOverrideConfig(BaseModel):
min_length=1,
description="Model name for this subagent (None = inherit from parent agent)",
)
thinking_enabled: bool | None = Field(
default=None,
description="Whether to enable thinking/reasoning for this subagent (None = no override)",
)
reasoning_effort: str | None = Field(
default=None,
description="Reasoning effort accepted or mapped by the selected model (None = use its default)",
)
skills: list[str] | None = Field(
default=None,
description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)",
@ -146,6 +154,14 @@ class CustomSubagentConfig(BaseModel):
default="inherit",
description="Model to use - 'inherit' uses parent's model",
)
thinking_enabled: bool | None = Field(
default=None,
description="Whether to enable thinking/reasoning for this subagent (None = no override)",
)
reasoning_effort: str | None = Field(
default=None,
description="Reasoning effort accepted or mapped by the selected model (None = use its default)",
)
max_turns: int = Field(
default=50,
ge=1,
@ -266,6 +282,20 @@ class SubagentsAppConfig(BaseModel):
return override.skills
return None
def get_thinking_enabled_for(self, agent_name: str) -> bool | None:
"""Return an explicit per-agent thinking override, including False."""
override = self.agents.get(agent_name)
if override is not None and override.thinking_enabled is not None:
return override.thinking_enabled
return None
def get_reasoning_effort_for(self, agent_name: str) -> str | None:
"""Return the per-agent effort request; the selected model resolves it."""
override = self.agents.get(agent_name)
if override is not None and override.reasoning_effort is not None:
return override.reasoning_effort
return None
def get_token_budget_for(
self,
agent_name: str,
@ -331,6 +361,10 @@ def load_subagents_config_from_dict(config_dict: dict) -> None:
parts.append(f"max_turns={override.max_turns}")
if override.model is not None:
parts.append(f"model={override.model}")
if override.thinking_enabled is not None:
parts.append(f"thinking_enabled={override.thinking_enabled}")
if override.reasoning_effort is not None:
parts.append(f"reasoning_effort={override.reasoning_effort}")
if override.skills is not None:
parts.append(f"skills={override.skills}")
if parts:

View File

@ -32,6 +32,8 @@ class ManagedSubagentDefinition(BaseModel):
disallowed_tools: list[str] = Field(default_factory=lambda: sorted(REQUIRED_DISALLOWED_TOOLS))
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

View File

@ -76,6 +76,8 @@ by lease/3. `tests/test_batch_lease_lifecycle.py` covers real SQLite interleavin
threaded initialization, peer cancellation and renewal drain.
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
Native worker reasoning settings come from YAML or managed definitions, with explicit per-agent overrides. Unset values request False/None, never parent thinking. Send original settings to the model factory to preserve validation and adjustment logs; record normalized effective settings in the assembly observer. Legacy stored definitions may omit both fields.
**Registry and managed definitions**: Resolve built-ins → `config.yaml custom_agents` → enabled admin-managed definitions → explicit `subagents.agents.<name>` overrides. Managed definitions are deployment-wide, use Custom Agents' `agent_storage.backend`, and remain stored but inactive on built-in/config name collisions. Default Lead Agent sees the full enabled catalog. Snapshot a Custom Agent's `allowed_subagents` into run metadata (`None` = all, `[]` = deny, list = allowlist); enforce it in prompt discovery and `task`, never reload mutable caller policy inside the tool.
**Benefit-based routing policy**: Default to direct execution. Permit `task` only when parallel latency, specialist skill or context isolation clearly outweigh startup, duplicate discovery, synthesis, state conflicts and side effects. Parallel scopes need independent outputs and disjoint mutable state; use the fewest useful agents, reassessing each batch. Bounded sequential delegation requires strong specialist/context benefit. A per-response limit of 1 permits only material specialist/context benefit: omit parallel/multi-batch benefits. Align `lead_agent/prompt.py`, the `task` description and built-in roles. Tests: `test_subagent_routing_prompt.py`, `test_subagent_prompt_security.py`, `test_lead_agent_prompt.py`.
**User-scoped Skills**: Use `get_or_new_user_skill_storage(user_id)` with parent runtime identity (`DEFAULT_USER_ID` only if absent), preserving lead-agent custom-skill shadowing/visibility; never use the global-only catalog.

View File

@ -25,6 +25,9 @@ class SubagentConfig:
allowed-tools is compatibility metadata only and never restricts
this subagent's tools.
model: Model to use - 'inherit' uses parent's model.
thinking_enabled: Whether to enable thinking/reasoning for this subagent.
None means use default (False).
reasoning_effort: Selected model's reasoning effort or None for its default.
max_turns: Maximum agent turns — model call plus the tools it runs —
before stopping. Built-in agents use the value set here
(general-purpose=150, bash=60) unless the global
@ -46,6 +49,8 @@ class SubagentConfig:
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
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
prompt_overlay: PromptOverlay = field(default_factory=PromptOverlay)

View File

@ -66,6 +66,8 @@ from deerflow.utils.file_io import await_drained
from deerflow.utils.messages import message_content_to_text
if TYPE_CHECKING:
from deerflow.models.reasoning import ResolvedReasoning
# Imported lazily at runtime inside _build_initial_state: importing
# tool_search eagerly would run tools/builtins/__init__ -> task_tool ->
# `from deerflow.subagents import SubagentExecutor`, which re-enters this
@ -989,6 +991,11 @@ class SubagentExecutor:
self.model_name: str | None = resolve_subagent_model_name(config, parent_model, app_config=app_config)
else:
self.model_name = None
# Resolve thinking_enabled: config explicit > False
self.thinking_enabled = config.thinking_enabled if config.thinking_enabled is not None else False
# Resolve reasoning_effort: config explicit > None
self.reasoning_effort = config.reasoning_effort
self.sandbox_state = sandbox_state
self.thread_data = thread_data
self.uploaded_files = deepcopy(uploaded_files) if uploaded_files is not None else None
@ -1100,7 +1107,24 @@ class SubagentExecutor:
app_config = self._get_resolved_app_config()
if self.model_name is None:
self.model_name = resolve_subagent_model_name(self.config, self.parent_model, app_config=app_config)
model = create_chat_model(name=self.model_name, thinking_enabled=False, app_config=app_config, attach_tracing=False)
from deerflow.models.reasoning import ReasoningPolicyError, resolve_reasoning_contract, resolve_reasoning_request
get_model_config = getattr(app_config, "get_model_config", None)
model_config = get_model_config(self.model_name) if callable(get_model_config) else None
if model_config is None:
model_config = next((profile for profile in getattr(app_config, "models", []) if profile.name == self.model_name), None)
try:
effective_reasoning = resolve_reasoning_request(resolve_reasoning_contract(model_config), thinking_enabled=self.thinking_enabled, reasoning_effort=self.reasoning_effort)
except ReasoningPolicyError as exc:
raise ValueError(f"Model {self.model_name}: {exc}") from exc
# Preserve the original request for the factory's validation and logs.
model = create_chat_model(
name=self.model_name,
thinking_enabled=self.thinking_enabled,
reasoning_effort=self.reasoning_effort,
app_config=app_config,
attach_tracing=False,
)
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
@ -1218,7 +1242,8 @@ class SubagentExecutor:
tool_node = tool_graph_node.data if tool_graph_node is not None else None
self._return_direct_tools = {tool.name for tool in tool_node.tools_by_name.values() if tool.return_direct} if isinstance(tool_node, ToolNode) else set()
self._describe_assembly(
app_config=app_config,
model_config=model_config,
effective_reasoning=effective_reasoning,
tools=bound_tools,
middlewares=middlewares,
deferred_setup=deferred_setup,
@ -1267,7 +1292,8 @@ class SubagentExecutor:
def _describe_assembly(
self,
*,
app_config: Any,
model_config: Any,
effective_reasoning: "ResolvedReasoning",
tools: list[Any],
middlewares: list[Any],
deferred_setup: "DeferredToolSetup | None",
@ -1289,8 +1315,6 @@ class SubagentExecutor:
from deerflow.extensions.notify import notify_agent_assembled
try:
get_model_config = getattr(app_config, "get_model_config", None)
model_config = get_model_config(self.model_name) if callable(get_model_config) else None
if model_config is None:
# A name the profile table does not know still has an identity;
# a missing profile must not blank out the whole descriptor.
@ -1302,12 +1326,6 @@ class SubagentExecutor:
supports_vision=False,
)
deferred_names = deferred_setup.deferred_names if deferred_setup is not None else frozenset()
# Subagents request thinking off; the model's reasoning contract
# decides what that means (a required-thinking model stays on), and
# the descriptor reports the effective policy the factory applied.
from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request
effective_reasoning = resolve_reasoning_request(resolve_reasoning_contract(model_config), thinking_enabled=False, reasoning_effort=None)
descriptor = build_assembly_descriptor(
namespace="deerflow",
agent_name=self.config.name,

View File

@ -49,6 +49,8 @@ def _build_custom_subagent_config(name: str, *, app_config: Any | None = None) -
disallowed_tools=custom.disallowed_tools,
skills=custom.skills,
model=custom.model,
thinking_enabled=custom.thinking_enabled,
reasoning_effort=custom.reasoning_effort,
max_turns=custom.max_turns,
timeout_seconds=custom.timeout_seconds,
)
@ -97,6 +99,8 @@ def _build_managed_subagent_config(name: str, *, app_config: Any | None = None)
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,
)
@ -110,7 +114,7 @@ def get_subagent_config(name: str, *, app_config: Any | None = None) -> Subagent
1. Built-in subagents (general-purpose, bash)
2. Custom subagents from config.yaml custom_agents section
3. Enabled administrator-managed subagents
4. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, skills)
4. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, thinking_enabled, reasoning_effort, skills)
Args:
name: The name of the subagent.
@ -173,6 +177,18 @@ def get_subagent_config(name: str, *, app_config: Any | None = None) -> Subagent
logger.debug("Subagent '%s': skills overridden (%s -> %s)", name, config.skills, effective_skills)
overrides["skills"] = effective_skills
# Thinking: per-agent override only (no global default)
effective_thinking = subagents_config.get_thinking_enabled_for(name)
if effective_thinking is not None and effective_thinking != config.thinking_enabled:
logger.debug("Subagent '%s': thinking_enabled overridden (%s -> %s)", name, config.thinking_enabled, effective_thinking)
overrides["thinking_enabled"] = effective_thinking
# Reasoning effort: per-agent override only (no global default)
effective_reasoning = subagents_config.get_reasoning_effort_for(name)
if effective_reasoning is not None and effective_reasoning != config.reasoning_effort:
logger.debug("Subagent '%s': reasoning_effort overridden (%s -> %s)", name, config.reasoning_effort, effective_reasoning)
overrides["reasoning_effort"] = effective_reasoning
if overrides:
config = replace(config, **overrides)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
@ -48,7 +49,7 @@ def sql_store(tmp_path):
def test_crud_and_signature(request, store_fixture):
store = request.getfixturevalue(store_fixture)
empty_signature = store.signature()
definition = _definition()
definition = _definition(thinking_enabled=True, reasoning_effort="xhigh")
store.create(definition)
assert store.get("RESEARCHER") == definition
@ -60,6 +61,8 @@ def test_crud_and_signature(request, store_fixture):
store.update(updated)
assert store.get("researcher").enabled is False
assert store.get("researcher").max_turns == 75
assert store.get("researcher").thinking_enabled is True
assert store.get("researcher").reasoning_effort == "xhigh"
updated_signature = store.signature()
assert updated_signature != created_signature
@ -70,6 +73,24 @@ def test_crud_and_signature(request, store_fixture):
store.get("researcher")
@pytest.mark.parametrize("store_fixture", ["file_store", "sql_store"])
def test_legacy_definition_without_reasoning_fields_keeps_defaults(request, store_fixture, tmp_path):
store = request.getfixturevalue(store_fixture)
legacy = {"name": "researcher", "description": "Research", "system_prompt": "Research the task."}
if store_fixture == "file_store":
root = tmp_path / "managed-subagents"
root.mkdir()
(root / "researcher.json").write_text(json.dumps(legacy), encoding="utf-8")
else:
with store._Session() as session:
session.add(ManagedSubagentRow(id="legacy", name="researcher", definition=legacy))
session.commit()
definition = store.get("researcher")
assert definition.thinking_enabled is None
assert definition.reasoning_effort is None
assert store.list() == [definition]
def test_sql_signature_detects_update_behind_existing_max_timestamp(sql_store):
sql_store.create(_definition("ahead"))
sql_store.create(_definition("writer"))

View File

@ -1080,6 +1080,8 @@ def test_subagent_executor_resolves_skill_authorization_for_chain(monkeypatch):
executor = SubagentExecutor.__new__(SubagentExecutor)
executor.config = SimpleNamespace(name="sub", skills=None, max_turns=25)
executor.model_name = "m"
executor.thinking_enabled = False
executor.reasoning_effort = None
executor.app_config = app_config
executor._resolved_app_config = app_config
executor._available_skill_names = None
@ -1444,6 +1446,8 @@ def test_subagent_executor_shares_one_skill_authorization_instance(monkeypatch):
executor = SubagentExecutor.__new__(SubagentExecutor)
executor.config = SimpleNamespace(name="sub", skills=None, max_turns=25)
executor.model_name = "m"
executor.thinking_enabled = False
executor.reasoning_effort = None
executor.app_config = app_config
executor._resolved_app_config = app_config
executor._available_skill_names = None

View File

@ -169,6 +169,8 @@ def _setup_executor_classes():
sandbox_provider_module = importlib.import_module("deerflow.sandbox.sandbox_provider")
sandbox_overwrite_module = importlib.import_module("deerflow.sandbox.overwrite")
sandbox_prewarm_module = importlib.import_module("deerflow.sandbox.prewarm")
for module_name in ("deerflow.models.factory", "deerflow.models.openai_codex_provider", "deerflow.agents.assembly_descriptor"):
importlib.import_module(module_name)
# Remove mocked executor if exists (from conftest.py)
if "deerflow.subagents.executor" in sys.modules:
@ -395,6 +397,105 @@ def msg(classes):
class TestAgentConstruction:
"""Test _create_agent() wiring before execution starts."""
@pytest.mark.parametrize(
"thinking,effort,mode,expected_thinking,expected_effort,expected_adjustment",
[
(None, None, "optional", False, "lite", None),
(True, "high", "optional", True, "deep", "effort_aliased"),
(False, None, "required", True, "lite", "thinking_forced_on"),
(True, None, "unsupported", False, None, "thinking_unsupported"),
(True, "unknown", "optional", True, "lite", "effort_unsupported_value"),
(True, "high", "legacy", True, "high", None),
(True, None, "legacy-unsupported", False, None, None),
],
)
def test_reasoning_factory_and_observer_agree(self, classes, base_config, monkeypatch, caplog, thinking, effort, mode, expected_thinking, expected_effort, expected_adjustment):
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.extensions.registry import ExtensionRegistry
from deerflow.subagents import executor as executor_module
factory = importlib.import_module("deerflow.models.factory")
profile_settings = {}
if mode in {"legacy", "legacy-unsupported"}:
profile_settings = {
"supports_thinking": mode == "legacy",
"supports_reasoning_effort": True,
"when_thinking_enabled": {"extra_body": {"thinking": {"type": "enabled"}}},
}
else:
reasoning = {"thinking": mode, "dialect": "openai_extra_body"}
if mode != "unsupported":
reasoning["effort"] = {"values": ["lite", "deep"], "default": "lite", "aliases": {"high": "deep"}}
profile_settings["reasoning"] = reasoning
profile = ModelConfig(name="child-model", use="langchain_openai:ChatOpenAI", model="child-model", **profile_settings)
app_config = AppConfig(models=[profile], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
unchanged = app_config.model_dump()
captured = {}
class RecordingModel:
def __init__(self, **kwargs):
captured.update(kwargs)
class Observer:
def on_agent_assembled(self, app_store, descriptor):
captured["descriptor"] = descriptor
registry = ExtensionRegistry()
with registry.attributed_to("reasoning-test"):
registry.agent_assembly_observer(Observer())
monkeypatch.setattr(factory, "resolve_class", lambda *args: RecordingModel)
monkeypatch.setattr(executor_module, "create_chat_model", factory.create_chat_model)
monkeypatch.setattr(executor_module, "create_agent", lambda **kwargs: SimpleNamespace(get_graph=lambda: SimpleNamespace(nodes={})))
monkeypatch.setitem(
sys.modules,
"deerflow.agents.middlewares.tool_error_handling_middleware",
_module("deerflow.agents.middlewares.tool_error_handling_middleware", build_subagent_runtime_middlewares=lambda **kwargs: []),
)
base_config.thinking_enabled = thinking
base_config.reasoning_effort = effort
executor = classes["SubagentExecutor"](config=base_config, tools=[], app_config=app_config, parent_model="child-model", extensions=registry.build())
if mode == "legacy-unsupported":
with pytest.raises(ValueError, match="Model child-model does not support thinking.*supports_thinking"):
asyncio.run(executor._create_agent())
assert not captured
assert app_config.model_dump() == unchanged
return
with caplog.at_level(logging.INFO, logger=factory.__name__):
asyncio.run(executor._create_agent())
if expected_adjustment is not None:
assert any(record.name == factory.__name__ and expected_adjustment in record.getMessage() for record in caplog.records)
descriptor = captured["descriptor"]
assert descriptor.thinking_enabled is expected_thinking
assert descriptor.reasoning_effort == expected_effort
assert captured.get("reasoning_effort") == expected_effort
assert captured["extra_body"]["thinking"]["type"] == ("enabled" if expected_thinking else "disabled")
assert app_config.model_dump() == unchanged
def test_required_thinking_rejection_names_model_before_creation(self, classes, base_config, monkeypatch):
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.models.reasoning import ReasoningPolicyError
from deerflow.subagents import executor as executor_module
profile = ModelConfig(name="required-worker-model", use="langchain_openai:ChatOpenAI", model="test-model", reasoning={"thinking": "required", "on_disable_request": "reject"})
app_config = AppConfig(models=[profile], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
model_factory = MagicMock(side_effect=AssertionError("model creation must not start"))
graph_factory = MagicMock(side_effect=AssertionError("graph creation must not start"))
monkeypatch.setattr(executor_module, "create_chat_model", model_factory)
monkeypatch.setattr(executor_module, "create_agent", graph_factory)
executor = classes["SubagentExecutor"](config=base_config, tools=[], app_config=app_config, parent_model=profile.name)
with pytest.raises(ValueError, match=f"Model {profile.name}: model requires thinking") as error:
asyncio.run(executor._create_agent())
assert isinstance(error.value.__cause__, ReasoningPolicyError)
model_factory.assert_not_called()
graph_factory.assert_not_called()
def test_create_agent_threads_explicit_app_config_to_model_and_middlewares(
self,
classes,
@ -460,6 +561,7 @@ class TestAgentConstruction:
assert captured["model"] == {
"name": "parent-model",
"thinking_enabled": False,
"reasoning_effort": None,
"app_config": app_config,
# attach_tracing=False pairs with graph-root tracing callbacks
# injected in _aexecute (see TestSubagentTracingWiring). Without

View File

@ -0,0 +1,41 @@
"""Native registry coverage for per-worker reasoning defaults and overrides."""
import pytest
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentOverrideConfig, SubagentsAppConfig
from deerflow.persistence.managed_subagents import ManagedSubagentDefinition
from deerflow.subagents import registry
@pytest.mark.parametrize("source", ["builtin", "config", "managed"])
def test_reasoning_settings_flow_through_registry_without_mutating_definitions(monkeypatch, source):
name = "general-purpose" if source == "builtin" else "researcher"
settings = {"thinking_enabled": True, "reasoning_effort": "deep"}
app_config = SubagentsAppConfig()
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [])
if source == "builtin":
app_config.agents[name] = SubagentOverrideConfig(**settings)
original = registry.BUILTIN_SUBAGENTS[name]
elif source == "config":
original = CustomSubagentConfig(description="Research", system_prompt="Research the task.", **settings)
app_config.custom_agents[name] = original
else:
original = ManagedSubagentDefinition(name=name, description="Research", system_prompt="Research the task.", **settings)
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [original])
configured = registry.get_subagent_config(name, app_config=app_config)
assert (configured.thinking_enabled, configured.reasoning_effort) == (True, "deep")
app_config.agents[name] = SubagentOverrideConfig(thinking_enabled=False, reasoning_effort="lite")
overridden = registry.get_subagent_config(name, app_config=app_config)
assert (overridden.thinking_enabled, overridden.reasoning_effort) == (False, "lite")
assert (configured.thinking_enabled, configured.reasoning_effort) == (True, "deep")
assert original.thinking_enabled is (None if source == "builtin" else True)
assert original.reasoning_effort == (None if source == "builtin" else "deep")
def test_unconfigured_workers_keep_reasoning_defaults(monkeypatch):
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [])
configured = registry.get_subagent_config("general-purpose", app_config=SubagentsAppConfig())
assert configured.thinking_enabled is None
assert configured.reasoning_effort is None

View File

@ -137,17 +137,23 @@ async def test_admin_can_create_update_and_delete_managed_subagent():
name="planner",
description="Plans creative work",
system_prompt="You are a creative planner.",
thinking_enabled=True,
reasoning_effort="xhigh",
),
)
assert created.source == "managed"
assert created.system_prompt == "You are a creative planner."
assert created.thinking_enabled is True
assert created.reasoning_effort == "xhigh"
updated = await router.update_managed_subagent(
"planner",
_request("admin"),
router.ManagedSubagentUpdateRequest(enabled=False),
router.ManagedSubagentUpdateRequest(enabled=False, thinking_enabled=False, reasoning_effort=None),
)
assert updated.enabled is False
assert updated.thinking_enabled is False
assert updated.reasoning_effort is None
await router.delete_managed_subagent("planner", _request("admin"))
with pytest.raises(HTTPException) as excinfo:

View File

@ -20,7 +20,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 63
config_version: 64
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
# lead_prompt_overlay:
@ -2036,6 +2036,8 @@ subagent_runtime:
# # token_budget: # per-agent override of the global token_budget above
# # max_tokens: 3000000 # raise the ceiling for deep-research tasks
# # model: qwen3:32b # Use a specific model (default: inherit from lead agent)
# # thinking_enabled: true # Enable thinking/reasoning for this subagent (default: false)
# # reasoning_effort: medium # Use an effort value or alias supported by this model
# # skills: # Skill discovery/activation allowlist (default: all enabled)
# # - web-search
# # - data-analysis
@ -2063,11 +2065,15 @@ subagent_runtime:
# # - data-analysis
# # - visualization
# # model: inherit # 'inherit' uses parent's model
# # thinking_enabled: true # Enable thinking/reasoning (default: false)
# # reasoning_effort: medium # Use an effort value or alias supported by this model
# # max_turns: 80
# # timeout_seconds: 600
#
# # Model override: by default, subagents inherit the lead agent's model.
# # Set `model` to use a different model (e.g., a local Ollama model for cost savings).
# # Unset thinking requests false; the selected model's required-thinking policy
# # and effort defaults/aliases still apply. Parent thinking settings are not inherited.
# # The model name must match a name defined in the `models:` section above.
# Durable native-subagent batches. Disabled by default because enabling it can

View File

@ -135,7 +135,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 63
config_version: 64
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -367,7 +367,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 63
config_version: 64
log_level: info
recursion_limit: 100
max_recursion_limit: 1000

View File

@ -9,6 +9,8 @@ export interface Subagent {
disallowed_tools: string[] | null;
skills: string[] | null;
model: string;
thinking_enabled?: boolean | null;
reasoning_effort?: string | null;
max_turns: number;
timeout_seconds: number;
enabled: boolean;
@ -27,6 +29,8 @@ export interface CreateManagedSubagentRequest {
disallowed_tools?: string[] | null;
skills?: string[] | null;
model?: string;
thinking_enabled?: boolean | null;
reasoning_effort?: string | null;
max_turns?: number;
timeout_seconds?: number;
enabled?: boolean;