mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
fix(gateway): prevent 400 error when client sends context with configurable (#1660)
* 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 <JasonOA888@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
82c3dbbc6b
commit
1fb5acee39
@ -129,26 +129,38 @@ def build_run_config(
|
|||||||
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
the LangGraph Platform-compatible HTTP API and the IM channel path behave
|
||||||
identically.
|
identically.
|
||||||
"""
|
"""
|
||||||
configurable: dict[str, Any] = {"thread_id": thread_id}
|
config: dict[str, Any] = {"recursion_limit": 100}
|
||||||
if request_config:
|
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.
|
# Inject custom agent name when the caller specified a non-default assistant.
|
||||||
# Honour an explicit configurable["agent_name"] in the request if already set.
|
# 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:
|
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID and "configurable" in config:
|
||||||
# Normalize the same way ChannelManager does: strip, lowercase,
|
if "agent_name" not in config["configurable"]:
|
||||||
# replace underscores with hyphens, then validate to prevent path
|
normalized = assistant_id.strip().lower().replace("_", "-")
|
||||||
# traversal and invalid agent directory lookups.
|
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
||||||
normalized = assistant_id.strip().lower().replace("_", "-")
|
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.")
|
||||||
if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized):
|
config["configurable"]["agent_name"] = 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 metadata:
|
if metadata:
|
||||||
config.setdefault("metadata", {}).update(metadata)
|
config.setdefault("metadata", {}).update(metadata)
|
||||||
return config
|
return config
|
||||||
|
|||||||
@ -90,6 +90,11 @@ async def run_agent(
|
|||||||
# Inject runtime context so middlewares can access thread_id
|
# Inject runtime context so middlewares can access thread_id
|
||||||
# (langgraph-cli does this automatically; we must do it manually)
|
# (langgraph-cli does this automatically; we must do it manually)
|
||||||
runtime = Runtime(context={"thread_id": thread_id}, store=store)
|
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
|
config.setdefault("configurable", {})["__pregel_runtime"] = runtime
|
||||||
|
|
||||||
runnable_config = RunnableConfig(**config)
|
runnable_config = RunnableConfig(**config)
|
||||||
|
|||||||
@ -109,17 +109,11 @@ def test_build_run_config_with_overrides():
|
|||||||
|
|
||||||
|
|
||||||
def test_build_run_config_custom_agent_injects_agent_name():
|
def test_build_run_config_custom_agent_injects_agent_name():
|
||||||
"""Custom assistant_id must be forwarded as configurable['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.
|
|
||||||
"""
|
|
||||||
from app.gateway.services import build_run_config
|
from app.gateway.services import build_run_config
|
||||||
|
|
||||||
config = build_run_config("thread-1", None, None, assistant_id="finalis")
|
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():
|
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,
|
None,
|
||||||
assistant_id="other-agent",
|
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():
|
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
|
assert resolve_agent_factory("custom-agent-123") is make_lead_agent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Regression tests for issue #1699:
|
# Regression tests for issue #1699:
|
||||||
# context field in langgraph-compat requests not merged into configurable
|
# 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():
|
def test_context_does_not_override_existing_configurable():
|
||||||
"""Values already in config.configurable must NOT be overridden by context.
|
"""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.
|
|
||||||
"""
|
|
||||||
from app.gateway.services import build_run_config
|
from app.gateway.services import build_run_config
|
||||||
|
|
||||||
config = 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
|
assert config["configurable"]["is_plan_mode"] is False
|
||||||
# New values should be added
|
# New values should be added
|
||||||
assert config["configurable"]["subagent_enabled"] is True
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user