mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-30 07:00:37 +00:00
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:
parent
30aa601f6a
commit
8d11aeb97b
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
41
backend/tests/test_subagent_thinking_config.py
Normal file
41
backend/tests/test_subagent_thinking_config.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user