From 2814bd5d498ae9c86f22b416f4691edbafa37192 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Mon, 14 Sep 2026 00:14:07 +0900 Subject: [PATCH] fix(models): stop reasoning_effort from reaching the constructor twice (#5403) * fix(models): stop reasoning_effort from reaching the constructor twice The regular lead-agent build forwards reasoning_effort to create_chat_model even when neither the request nor the custom agent chose one. The factory spread that kwarg next to the profile settings, so any profile that also yielded reasoning_effort -- a top-level value, when_thinking_enabled, when_thinking_disabled, or the minimal effort the extra_body.thinking disable path injects -- made the constructor raise "got multiple values for keyword argument 'reasoning_effort'", and the lead agent could not be built for that model. #2017 moved the factory-injected value out of kwargs but left the caller-supplied one. The requested effort now leaves kwargs once and layers like model_overrides: a non-None value replaces the profile value, None keeps it, and the thinking transforms applied afterwards still decide the final value. Codex keeps resolving the requested value itself, so a level it does not accept and a profile without effort support still fall back to medium. * docs(changelog): reference #5403 in the reasoning_effort collision fix entry --- CHANGELOG.md | 11 +++ CHANGELOG_zh.md | 8 +++ .../harness/deerflow/models/AGENTS.md | 1 + .../harness/deerflow/models/factory.py | 22 ++++-- .../tests/test_lead_agent_model_resolution.py | 39 ++++++++++ backend/tests/test_model_factory.py | 72 ++++++++++++++++++- 6 files changed, 145 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eefc51fb5..7d8d62fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -582,6 +582,16 @@ This section accumulates work toward the **2.1.0** milestone ### Fixed +- **models:** Stop the lead agent from failing to build whenever a model with + `supports_reasoning_effort: true` also gets a `reasoning_effort` from its + profile — at the top level, in `when_thinking_enabled` or + `when_thinking_disabled`, or from the `extra_body.thinking` disable path. The + regular lead-agent build forwards the requested effort even when unset, so + the key reached the provider constructor twice and raised `TypeError: got + multiple values for keyword argument 'reasoning_effort'`. The requested value now + layers like per-agent `model_settings`: it replaces a top-level profile + value, an unset request keeps that value, and the thinking-mode settings still + decide the final one. Codex keeps its own level check. ([#5403]) - **runtime:** Stop a keyed run retry from failing with 500 on the SQL run store. HTTP admissions do not pass a `user_id`; the SQL store stamps the request user on the row, but the process-local run record kept `None`. A @@ -2785,3 +2795,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5357]: https://github.com/bytedance/deer-flow/pull/5357 [#5393]: https://github.com/bytedance/deer-flow/pull/5393 [#5401]: https://github.com/bytedance/deer-flow/pull/5401 +[#5403]: https://github.com/bytedance/deer-flow/pull/5403 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 58d1a2cb8..b0f253a3a 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -397,6 +397,13 @@ ### 修复 +- **模型:** 当 `supports_reasoning_effort: true` 的模型同时从 profile 获得 + `reasoning_effort`(顶层、`when_thinking_enabled` 或 `when_thinking_disabled` + 中,或由 `extra_body.thinking` 的关闭路径注入)时,lead agent 不再构建失败。lead + agent 的常规构建总会转发请求的 effort(即使未设置),导致该参数两次传给 provider 构造函数,抛出 + `TypeError: got multiple values for keyword argument 'reasoning_effort'`。现在请求值 + 按每个 agent 的 `model_settings` 方式叠加:替换 profile 顶层的值,未设置时保留该值, + 最终值仍由 thinking 模式相关设置决定。Codex 保留自己的级别校验。([#5403]) - **运行时:** 带 `Idempotency-Key` 的 run 重试在 SQL run 存储上不再返回 500。HTTP 准入不会传入 `user_id`,SQL 存储会把请求用户写入该行,但进程内的 run 记录仍为 `None`。同一 key 的重试若落到另一个 Gateway worker,或在已完成的 run 被清理后回到 @@ -2135,3 +2142,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5357]: https://github.com/bytedance/deer-flow/pull/5357 [#5393]: https://github.com/bytedance/deer-flow/pull/5393 [#5401]: https://github.com/bytedance/deer-flow/pull/5401 +[#5403]: https://github.com/bytedance/deer-flow/pull/5403 diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index d1d4470a2..7d28b7cfc 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -3,6 +3,7 @@ - `create_chat_model(name, thinking_enabled)` instantiates LLM from config via reflection - Supports `thinking_enabled` flag with per-model `when_thinking_enabled` overrides - Supports vLLM-style thinking toggles via `when_thinking_enabled.extra_body.chat_template_kwargs.enable_thinking` for Qwen reasoning models, while normalizing legacy `thinking` configs for backward compatibility +- A per-request `reasoning_effort` kwarg (the regular, non-bootstrap lead-agent build passes it even when `None`) is popped from `kwargs` and layered like `model_overrides`: a non-`None` value replaces the profile's, and the thinking transforms applied afterwards (`when_thinking_enabled`, `when_thinking_disabled`, the `extra_body.thinking` disable path) still decide the final value. Never let a key reach the constructor through both `kwargs` and the profile settings — Python raises `got multiple values for keyword argument` and the lead agent cannot be built for that model. Codex checks the requested level itself. Pinned by `tests/test_model_factory.py` and `tests/test_lead_agent_model_resolution.py` - Supports `supports_vision` flag for image understanding models - Config values starting with `$` resolved as environment variables - Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`) diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index 8ebadd3ad..f965135d6 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.py @@ -236,6 +236,17 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * # value exactly as it would a profile-native one. if model_overrides: model_settings_from_config.update({key: value for key, value in model_overrides.items() if value is not None}) + # The per-request reasoning effort layers the same way. The regular lead-agent + # build forwards the key even when None (neither the request nor the custom + # agent chose one), so it must leave kwargs: a profile that also yields + # reasoning_effort would otherwise hand the constructor the keyword twice. + # Codex validates and maps the requested value itself below. + from deerflow.models.openai_codex_provider import CodexChatModel + + is_codex_model = issubclass(model_class, CodexChatModel) + requested_reasoning_effort = kwargs.pop("reasoning_effort", None) + if requested_reasoning_effort is not None and not is_codex_model: + model_settings_from_config["reasoning_effort"] = requested_reasoning_effort # Compute effective when_thinking_enabled by merging in the `thinking` shortcut field. # The `thinking` shortcut is equivalent to setting when_thinking_enabled["thinking"]. has_thinking_settings = (model_config.when_thinking_enabled is not None) or (model_config.thinking is not None) @@ -269,7 +280,7 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * # Native langchain_anthropic: thinking is a direct constructor parameter model_settings_from_config["thinking"] = {"type": "disabled"} if not model_config.supports_reasoning_effort: - kwargs.pop("reasoning_effort", None) + requested_reasoning_effort = None model_settings_from_config.pop("reasoning_effort", None) # Normalize the api_base -> base_url alias FIRST, so the downstream OpenAI-compatible @@ -278,18 +289,15 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * _apply_stream_chunk_timeout_default(model_class, model_settings_from_config) # For Codex Responses API models: map thinking mode to reasoning_effort - from deerflow.models.openai_codex_provider import CodexChatModel - - if issubclass(model_class, CodexChatModel): + if is_codex_model: # The ChatGPT Codex endpoint currently rejects max_tokens/max_output_tokens. model_settings_from_config.pop("max_tokens", None) # Use explicit reasoning_effort from frontend if provided (low/medium/high) - explicit_effort = kwargs.pop("reasoning_effort", None) if not thinking_enabled: model_settings_from_config["reasoning_effort"] = "none" - elif explicit_effort and explicit_effort in ("low", "medium", "high", "xhigh"): - model_settings_from_config["reasoning_effort"] = explicit_effort + elif requested_reasoning_effort in ("low", "medium", "high", "xhigh"): + model_settings_from_config["reasoning_effort"] = requested_reasoning_effort elif "reasoning_effort" not in model_settings_from_config: model_settings_from_config["reasoning_effort"] = "medium" diff --git a/backend/tests/test_lead_agent_model_resolution.py b/backend/tests/test_lead_agent_model_resolution.py index 585dfae58..4839693cc 100644 --- a/backend/tests/test_lead_agent_model_resolution.py +++ b/backend/tests/test_lead_agent_model_resolution.py @@ -287,6 +287,45 @@ def test_internal_make_lead_agent_uses_explicit_app_config(monkeypatch): assert result["model"] is not None +@pytest.mark.parametrize( + ("runtime_options", "expected_effort"), + [ + pytest.param({}, "high", id="request-leaves-effort-unset"), + pytest.param({"reasoning_effort": "low"}, "low", id="request-chooses-effort"), + ], +) +def test_internal_make_lead_agent_builds_model_whose_profile_sets_reasoning_effort(monkeypatch, runtime_options, expected_effort): + """The regular lead-agent build forwards ``reasoning_effort`` even when it is + None, so the real factory must merge it with a profile-level value instead of + handing the constructor the keyword twice (which raised ``TypeError``).""" + model = ModelConfig( + name="effort-model", + display_name="effort-model", + description=None, + use="langchain_openai:ChatOpenAI", + model="effort-model", + api_key="test-key", + reasoning_effort="high", + supports_thinking=False, + supports_reasoning_effort=True, + supports_vision=False, + ) + app_config = _make_app_config([model]) + + import deerflow.tools as tools_module + + monkeypatch.setattr(tools_module, "get_available_tools", lambda **kwargs: []) + monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda config, model_name, agent_name=None, **kwargs: []) + monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs) + + result = lead_agent_module._make_lead_agent( + {"configurable": {"model_name": "effort-model", **runtime_options}}, + app_config=app_config, + ) + + assert result["model"].reasoning_effort == expected_effort + + @pytest.mark.parametrize("is_bootstrap", [False, True]) def test_internal_make_lead_agent_selects_and_normalizes_delta_state(monkeypatch, is_bootstrap): app_config = _make_app_config([_make_model("delta-model", supports_thinking=False)]) diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index 35952f6f7..dd816bbd0 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -972,6 +972,35 @@ def test_codex_provider_defaults_reasoning_effort_to_medium(monkeypatch): assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "medium" +@pytest.mark.parametrize( + ("supports_reasoning_effort", "requested_effort"), + [ + pytest.param(True, "minimal", id="value-outside-codex-levels"), + pytest.param(False, "high", id="profile-without-effort-support"), + ], +) +def test_codex_provider_falls_back_to_medium_for_request_it_cannot_honor(monkeypatch, supports_reasoning_effort, requested_effort): + """Codex resolves the requested effort itself; the generic request layering + must not smuggle a value past its level check or the capability guard.""" + cfg = _make_app_config( + [ + _make_model( + "codex", + use="deerflow.models.openai_codex_provider:CodexChatModel", + supports_thinking=True, + supports_reasoning_effort=supports_reasoning_effort, + ) + ] + ) + _patch_factory(monkeypatch, cfg, model_class=FakeCodexChatModel) + monkeypatch.setattr(codex_provider_module, "CodexChatModel", FakeCodexChatModel) + + FakeChatModel.captured_kwargs = {} + factory_module.create_chat_model(name="codex", thinking_enabled=True, reasoning_effort=requested_effort) + + assert FakeChatModel.captured_kwargs.get("reasoning_effort") == "medium" + + def test_codex_provider_strips_unsupported_max_tokens(monkeypatch): cfg = _make_app_config( [ @@ -1231,10 +1260,51 @@ def test_no_duplicate_kwarg_when_reasoning_effort_in_config_and_thinking_disable # Must not raise TypeError factory_module.create_chat_model(name="doubao-model", thinking_enabled=False) - # kwargs (runtime) takes precedence: thinking-disabled path sets reasoning_effort=minimal + # The thinking-disabled path governs the profile value: it sets reasoning_effort=minimal assert captured.get("reasoning_effort") == "minimal" +@pytest.mark.parametrize( + ("profile", "thinking_enabled", "requested_effort", "expected_effort"), + [ + pytest.param({"reasoning_effort": "high"}, True, None, "high", id="unset-request-keeps-profile-value"), + pytest.param({"reasoning_effort": "high"}, True, "low", "low", id="request-replaces-profile-value"), + pytest.param({"when_thinking_enabled": {"reasoning_effort": "medium"}}, True, "high", "medium", id="thinking-enabled-settings-govern-request"), + pytest.param({"when_thinking_enabled": {"extra_body": {"thinking": {"type": "enabled"}}}}, False, "high", "minimal", id="extra-body-disable-path-governs-request"), + pytest.param({"when_thinking_disabled": {"reasoning_effort": "low"}}, False, "high", "low", id="thinking-disabled-settings-govern-request"), + ], +) +def test_requested_reasoning_effort_layers_over_profile_value(profile, thinking_enabled, requested_effort, expected_effort): + """The regular lead-agent build forwards ``reasoning_effort`` even when None + (neither the request nor the custom agent chose one). When the profile also yields one, + the real ChatOpenAI must still build instead of raising ``got multiple + values for keyword argument 'reasoning_effort'``, and the request must layer + like ``model_overrides``: it replaces a profile value, None never clobbers + one, and the thinking settings still govern the result.""" + model = ModelConfig( + name="effort-profile", + display_name="Effort Profile", + description=None, + use="langchain_openai:ChatOpenAI", + model="effort-profile", + api_key="test-key", + supports_thinking=True, + supports_reasoning_effort=True, + supports_vision=False, + **profile, + ) + + chat_model = factory_module.create_chat_model( + name="effort-profile", + thinking_enabled=thinking_enabled, + reasoning_effort=requested_effort, + app_config=_make_app_config([model]), + attach_tracing=False, + ) + + assert chat_model._get_request_payload([HumanMessage(content="ping")])["reasoning_effort"] == expected_effort + + # --------------------------------------------------------------------------- # stream_chunk_timeout default injection (issue #3189) # ---------------------------------------------------------------------------