From 8d11aeb97b376e4a0e6723c200c590ef98e77ae8 Mon Sep 17 00:00:00 2001 From: Totoro Date: Thu, 29 Oct 2026 22:26:12 +0800 Subject: [PATCH] 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 Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> --- README.md | 2 + backend/app/gateway/routers/subagents.py | 12 ++- .../deerflow/config/subagents_config.py | 34 ++++++ .../persistence/managed_subagents/base.py | 2 + .../harness/deerflow/subagents/AGENTS.md | 2 + .../harness/deerflow/subagents/config.py | 5 + .../harness/deerflow/subagents/executor.py | 40 +++++-- .../harness/deerflow/subagents/registry.py | 18 +++- backend/tests/test_managed_subagent_store.py | 23 +++- backend/tests/test_skills_authorization.py | 4 + backend/tests/test_subagent_executor.py | 102 ++++++++++++++++++ .../tests/test_subagent_thinking_config.py | 41 +++++++ backend/tests/test_subagents_router.py | 8 +- config.example.yaml | 8 +- deploy/helm/deer-flow/README.md | 2 +- deploy/helm/deer-flow/values.yaml | 2 +- frontend/src/core/subagents/types.ts | 4 + 17 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_subagent_thinking_config.py diff --git a/README.md b/README.md index dd4938670a..66cab51a9d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/gateway/routers/subagents.py b/backend/app/gateway/routers/subagents.py index 948534cf90..739bb87a22 100644 --- a/backend/app/gateway/routers/subagents.py +++ b/backend/app/gateway/routers/subagents.py @@ -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", diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index 8265a27a66..e6596938ed 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -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: diff --git a/backend/packages/harness/deerflow/persistence/managed_subagents/base.py b/backend/packages/harness/deerflow/persistence/managed_subagents/base.py index 1204b44abf..41e8fab535 100644 --- a/backend/packages/harness/deerflow/persistence/managed_subagents/base.py +++ b/backend/packages/harness/deerflow/persistence/managed_subagents/base.py @@ -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 diff --git a/backend/packages/harness/deerflow/subagents/AGENTS.md b/backend/packages/harness/deerflow/subagents/AGENTS.md index 4d83c35b2c..7190a6c38b 100644 --- a/backend/packages/harness/deerflow/subagents/AGENTS.md +++ b/backend/packages/harness/deerflow/subagents/AGENTS.md @@ -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.` 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. diff --git a/backend/packages/harness/deerflow/subagents/config.py b/backend/packages/harness/deerflow/subagents/config.py index 7213c84412..9fffa1acd2 100644 --- a/backend/packages/harness/deerflow/subagents/config.py +++ b/backend/packages/harness/deerflow/subagents/config.py @@ -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) diff --git a/backend/packages/harness/deerflow/subagents/executor.py b/backend/packages/harness/deerflow/subagents/executor.py index c701f0ed99..7a69792e49 100644 --- a/backend/packages/harness/deerflow/subagents/executor.py +++ b/backend/packages/harness/deerflow/subagents/executor.py @@ -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, diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index f4601ea6fb..049f03f8c4 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -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) diff --git a/backend/tests/test_managed_subagent_store.py b/backend/tests/test_managed_subagent_store.py index 0cfe035fdf..88606c9c9a 100644 --- a/backend/tests/test_managed_subagent_store.py +++ b/backend/tests/test_managed_subagent_store.py @@ -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")) diff --git a/backend/tests/test_skills_authorization.py b/backend/tests/test_skills_authorization.py index 8a552ba1eb..7528e277ec 100644 --- a/backend/tests/test_skills_authorization.py +++ b/backend/tests/test_skills_authorization.py @@ -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 diff --git a/backend/tests/test_subagent_executor.py b/backend/tests/test_subagent_executor.py index ff359df848..f28450002a 100644 --- a/backend/tests/test_subagent_executor.py +++ b/backend/tests/test_subagent_executor.py @@ -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 diff --git a/backend/tests/test_subagent_thinking_config.py b/backend/tests/test_subagent_thinking_config.py new file mode 100644 index 0000000000..5512c0ce68 --- /dev/null +++ b/backend/tests/test_subagent_thinking_config.py @@ -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 diff --git a/backend/tests/test_subagents_router.py b/backend/tests/test_subagents_router.py index 5525e316da..15684c9b4e 100644 --- a/backend/tests/test_subagents_router.py +++ b/backend/tests/test_subagents_router.py @@ -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: diff --git a/config.example.yaml b/config.example.yaml index b4d17baa84..8061f4a78a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/deploy/helm/deer-flow/README.md b/deploy/helm/deer-flow/README.md index 840cf9488e..38e3743942 100644 --- a/deploy/helm/deer-flow/README.md +++ b/deploy/helm/deer-flow/README.md @@ -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 diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 73c80a13c3..e2c6b07693 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -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 diff --git a/frontend/src/core/subagents/types.ts b/frontend/src/core/subagents/types.ts index 6b8e9c2e03..a5c3ef1cce 100644 --- a/frontend/src/core/subagents/types.ts +++ b/frontend/src/core/subagents/types.ts @@ -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;