From 1fb5acee3956338f4844f51afb6e30a79219a14f Mon Sep 17 00:00:00 2001 From: Jason <101583541+JasonOA888@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:21:32 +0800 Subject: [PATCH] fix(gateway): prevent 400 error when client sends context with configurable (#1660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): prevent 400 error when client sends context with configurable Fixes #1290 LangGraph >= 0.6.0 rejects requests that include both 'configurable' and 'context' in the run config. If the client (e.g. useStream hook) sends a 'context' key, we now honour it and skip creating our own 'configurable' dict to avoid the conflict. When no 'context' is provided, we fall back to the existing 'configurable' behaviour with thread_id. * fix(gateway): address review feedback — warn on dual keys, fix runtime injection, add tests - Log a warning when client sends both 'context' and 'configurable' so it's no longer silently dropped (reviewer feedback) - Ensure thread_id is available in config['context'] when present so middlewares can find it there too - Add test coverage for the context path, the both-keys-present case, passthrough of other keys, and the no-config fallback * style: ruff format services.py --------- Co-authored-by: JasonOA888 Co-authored-by: Willem Jiang --- backend/app/gateway/services.py | 44 ++++++---- .../harness/deerflow/runtime/runs/worker.py | 5 ++ backend/tests/test_gateway_services.py | 84 +++++++++++++++---- 3 files changed, 103 insertions(+), 30 deletions(-) diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index dffedce1c..272801b6a 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -129,26 +129,38 @@ def build_run_config( the LangGraph Platform-compatible HTTP API and the IM channel path behave identically. """ - configurable: dict[str, Any] = {"thread_id": thread_id} + config: dict[str, Any] = {"recursion_limit": 100} if request_config: - configurable.update(request_config.get("configurable", {})) + # LangGraph >= 0.6.0 introduced ``context`` as the preferred way to + # pass thread-level data and rejects requests that include both + # ``configurable`` and ``context``. If the caller already sends + # ``context``, honour it and skip our own ``configurable`` dict. + if "context" in request_config: + if "configurable" in request_config: + logger.warning( + "build_run_config: client sent both 'context' and 'configurable'; preferring 'context' (LangGraph >= 0.6.0). thread_id=%s, caller_configurable keys=%s", + thread_id, + list(request_config.get("configurable", {}).keys()), + ) + config["context"] = request_config["context"] + else: + configurable = {"thread_id": thread_id} + configurable.update(request_config.get("configurable", {})) + config["configurable"] = configurable + for k, v in request_config.items(): + if k not in ("configurable", "context"): + config[k] = v + else: + config["configurable"] = {"thread_id": thread_id} # Inject custom agent name when the caller specified a non-default assistant. # Honour an explicit configurable["agent_name"] in the request if already set. - if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "agent_name" not in configurable: - # Normalize the same way ChannelManager does: strip, lowercase, - # replace underscores with hyphens, then validate to prevent path - # traversal and invalid agent directory lookups. - normalized = assistant_id.strip().lower().replace("_", "-") - if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): - raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") - configurable["agent_name"] = normalized - - config: dict[str, Any] = {"configurable": configurable, "recursion_limit": 100} - if request_config: - for k, v in request_config.items(): - if k != "configurable": - config[k] = v + if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "configurable" in config: + if "agent_name" not in config["configurable"]: + normalized = assistant_id.strip().lower().replace("_", "-") + if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): + raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") + config["configurable"]["agent_name"] = normalized if metadata: config.setdefault("metadata", {}).update(metadata) return config diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index deaec055a..2d67ecb27 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -90,6 +90,11 @@ async def run_agent( # Inject runtime context so middlewares can access thread_id # (langgraph-cli does this automatically; we must do it manually) runtime = Runtime(context={"thread_id": thread_id}, store=store) + # If the caller already set a ``context`` key (LangGraph >= 0.6.0 + # prefers it over ``configurable`` for thread-level data), make + # sure ``thread_id`` is available there too. + if "context" in config and isinstance(config["context"], dict): + config["context"].setdefault("thread_id", thread_id) config.setdefault("configurable", {})["__pregel_runtime"] = runtime runnable_config = RunnableConfig(**config) diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 6eab20a9f..782306e38 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -109,17 +109,11 @@ def test_build_run_config_with_overrides(): def test_build_run_config_custom_agent_injects_agent_name(): - """Custom assistant_id must be forwarded as configurable['agent_name']. - - Regression test for #1644: when the LangGraph Platform-compatible - /runs endpoint receives a custom assistant_id (e.g. 'finalis'), the - Gateway must inject configurable['agent_name'] so that make_lead_agent - loads the correct agents/finalis/SOUL.md. - """ + """Custom assistant_id must be forwarded as configurable['agent_name'].""" from app.gateway.services import build_run_config config = build_run_config("thread-1", None, None, assistant_id="finalis") - assert config["configurable"]["agent_name"] == "finalis", "Custom assistant_id must be forwarded as configurable['agent_name'] so that make_lead_agent loads the correct SOUL.md" + assert config["configurable"]["agent_name"] == "finalis" def test_build_run_config_lead_agent_no_agent_name(): @@ -148,7 +142,7 @@ def test_build_run_config_explicit_agent_name_not_overwritten(): None, assistant_id="other-agent", ) - assert config["configurable"]["agent_name"] == "explicit-agent", "An explicit configurable['agent_name'] in the request body must not be overwritten by the assistant_id mapping" + assert config["configurable"]["agent_name"] == "explicit-agent" def test_resolve_agent_factory_returns_make_lead_agent(): @@ -162,6 +156,8 @@ def test_resolve_agent_factory_returns_make_lead_agent(): assert resolve_agent_factory("custom-agent-123") is make_lead_agent +# --------------------------------------------------------------------------- + # --------------------------------------------------------------------------- # Regression tests for issue #1699: # context field in langgraph-compat requests not merged into configurable @@ -246,11 +242,7 @@ def test_context_merges_into_configurable(): def test_context_does_not_override_existing_configurable(): - """Values already in config.configurable must NOT be overridden by context. - - This ensures that explicit configurable values from the ``config`` field - take precedence over the ``context`` field. - """ + """Values already in config.configurable must NOT be overridden by context.""" from app.gateway.services import build_run_config config = build_run_config( @@ -284,3 +276,67 @@ def test_context_does_not_override_existing_configurable(): assert config["configurable"]["is_plan_mode"] is False # New values should be added assert config["configurable"]["subagent_enabled"] is True + + +# --------------------------------------------------------------------------- +# build_run_config — context / configurable precedence (LangGraph >= 0.6.0) +# --------------------------------------------------------------------------- + + +def test_build_run_config_with_context(): + """When caller sends 'context', prefer it over 'configurable'.""" + from app.gateway.services import build_run_config + + config = build_run_config( + "thread-1", + {"context": {"user_id": "u-42", "thread_id": "thread-1"}}, + None, + ) + assert "context" in config + assert config["context"]["user_id"] == "u-42" + assert "configurable" not in config + assert config["recursion_limit"] == 100 + + +def test_build_run_config_context_plus_configurable_warns(caplog): + """When caller sends both 'context' and 'configurable', prefer 'context' and log a warning.""" + import logging + + from app.gateway.services import build_run_config + + with caplog.at_level(logging.WARNING, logger="app.gateway.services"): + config = build_run_config( + "thread-1", + { + "context": {"user_id": "u-42"}, + "configurable": {"model_name": "gpt-4"}, + }, + None, + ) + assert "context" in config + assert config["context"]["user_id"] == "u-42" + assert "configurable" not in config + assert any("both 'context' and 'configurable'" in r.message for r in caplog.records) + + +def test_build_run_config_context_passthrough_other_keys(): + """Non-conflicting keys from request_config are still passed through when context is used.""" + from app.gateway.services import build_run_config + + config = build_run_config( + "thread-1", + {"context": {"thread_id": "thread-1"}, "tags": ["prod"]}, + None, + ) + assert config["context"]["thread_id"] == "thread-1" + assert "configurable" not in config + assert config["tags"] == ["prod"] + + +def test_build_run_config_no_request_config(): + """When request_config is None, fall back to basic configurable with thread_id.""" + from app.gateway.services import build_run_config + + config = build_run_config("thread-abc", None, None) + assert config["configurable"] == {"thread_id": "thread-abc"} + assert "context" not in config