fix(gateway): answer malformed run bodies with 4xx instead of 500 (#7362)

* fix(gateway): answer malformed run bodies with 4xx instead of 500

RunCreateRequest types config, context and input as free-form dicts, so
some bodies it accepts still failed during run admission with an
exception nothing mapped, and the run routes answered 500:

- a non-object config.configurable (build_run_config's dict.update, and
  .get in the stateless thread selector)
- a non-object config.context (build_run_config's own ValueError)
- an assistant_id that does not normalize to letters, digits and hyphens
- a non-string agent_name in config.configurable, config.context or
  context (_load_scope_agent_config)
- an AI message whose tool_calls is a string, object or list of strings
  (AttributeError from LangChain's validator)

The request model now requires config.configurable and config.context to
be objects or null (422 invalid_run_config_section), and
build_run_config checks configurable as it already checked context.
start_run maps build_run_config's ValueError to 422, as it does for
validate_thread_id. Message conversion also maps AttributeError to its
existing 400, and a non-string agent_name gets the existing 422 for an
unresolvable agent. All of these fail before admission, so no run is
created.

* docs(changelog): reference #7362 for the malformed run body fix

* docs(changelog): note the configurable shapes #7362 now rejects
This commit is contained in:
Hyeonsang Cho 2026-10-24 11:12:05 +09:00 committed by GitHub
parent d0d66d62fa
commit 5bda01f524
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 204 additions and 7 deletions

View File

@ -1397,6 +1397,15 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**.
### Fixed
- **gateway:** Run requests with a malformed body now get a client error
instead of `500 Internal Server Error`. A `config.configurable` or
`config.context` that is not an object, an invalid `assistant_id`, a
non-string `agent_name`, or an AI message whose `tool_calls` is not a list of
objects used to fail run admission unhandled. They now return `422` (`400`
for the message) before a run is created. On thread routes, a
`config.configurable` of `[]`, `""`, `0`, `false` or a list of key/value
pairs used to be accepted and is now rejected with `422` too. ([#7362])
- **tui:** Quitting after `Ctrl+C` interrupted a run no longer hangs on the
step that run is still finishing. The run thread was joined on exit, so the
TUI closed but the shell prompt stayed blocked until a long tool call or model
@ -13035,3 +13044,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#7277]: https://github.com/bytedance/deer-flow/pull/7277
[#7325]: https://github.com/bytedance/deer-flow/pull/7325
[#7326]: https://github.com/bytedance/deer-flow/pull/7326
[#7362]: https://github.com/bytedance/deer-flow/pull/7362

View File

@ -1112,6 +1112,13 @@
### 修复
- **网关:** 请求体格式错误的运行请求现在返回客户端错误,而不是
`500 Internal Server Error`。此前 `config.configurable` 或 `config.context`
不是对象、`assistant_id` 无效、`agent_name` 不是字符串,或 AI 消息的 `tool_calls`
不是对象列表时,运行准入会抛出未处理的异常。现在这些请求在创建运行之前即返回
`422`(消息问题返回 `400`)。在线程路由上,此前会被接受的 `config.configurable`
取值 `[]`、`""`、`0`、`false` 或键值对列表,现在同样返回 `422`。([#7362])
- **TUI:** 按 `Ctrl+C` 中断运行后再退出,不再卡在该运行仍在完成的步骤上。此前运行
线程会在退出时被等待,TUI 已关闭但 shell 提示符一直阻塞,直到较长的工具调用或模型请求
返回,再按 `Ctrl+C` 也只会打印 traceback。现在退出时会提示正在等待该步骤,并在其完成后
@ -10405,3 +10412,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#7277]: https://github.com/bytedance/deer-flow/pull/7277
[#7325]: https://github.com/bytedance/deer-flow/pull/7325
[#7326]: https://github.com/bytedance/deer-flow/pull/7326
[#7362]: https://github.com/bytedance/deer-flow/pull/7362

View File

@ -97,6 +97,25 @@ class RunCreateRequest(BaseModel):
lifted["conversation_references"] = references
return lifted
@field_validator("config")
@classmethod
def reject_non_object_config_sections(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
"""``config.configurable`` and ``config.context`` are objects or null.
Run admission and the stateless thread selector read both as mappings,
so any other JSON value must be a validation error, not a Gateway fault.
"""
if value is None:
return value
for section in ("configurable", "context"):
if value.get(section) is not None and not isinstance(value[section], dict):
raise PydanticCustomError(
"invalid_run_config_section",
"config.{section} must be an object or null",
{"section": section},
)
return value
@model_validator(mode="after")
def validate_configurable_thread_id(self) -> RunCreateRequest:
"""Validate the stateless-run thread selector inside RunnableConfig."""

View File

@ -599,8 +599,10 @@ def _normalize_input_messages(
for index, item in enumerate(messages):
try:
message = convert_to_messages([item])[0]
except (ValueError, TypeError, NotImplementedError, KeyError) as exc:
# LangChain's error may contain the complete caller message.
except (ValueError, TypeError, NotImplementedError, KeyError, AttributeError) as exc:
# LangChain's error may contain the complete caller message. Its
# message validators raise AttributeError for some wrong-typed
# fields, e.g. a string ``tool_calls`` on an AI message.
raise HTTPException(
status_code=400,
detail=f"Invalid message at {location}[{index}]",
@ -1290,6 +1292,9 @@ def build_run_config(
default_recursion_limit, max_recursion_limit = _resolve_gateway_recursion_limits()
config: dict[str, Any] = {"recursion_limit": default_recursion_limit}
if request_config:
caller_configurable = request_config.get("configurable")
if caller_configurable is not None and not isinstance(caller_configurable, Mapping):
raise ValueError("request config 'configurable' must be a mapping or null.")
# 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
@ -1299,7 +1304,7 @@ def build_run_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") or {}).keys()),
list((caller_configurable or {}).keys()),
)
context_value = request_config["context"]
if context_value is None:
@ -1326,7 +1331,7 @@ def build_run_config(
config["configurable"] = {"thread_id": thread_id}
else:
configurable = {"thread_id": thread_id}
configurable.update(request_config.get("configurable") or {})
configurable.update(caller_configurable or {})
configurable["thread_id"] = thread_id
config["configurable"] = configurable
for k, v in request_config.items():
@ -1959,13 +1964,16 @@ def _current_human_message(graph_input: object) -> HumanMessage | None:
async def _load_scope_agent_config(
*,
assistant_id: str | None,
assistant_id: object,
user_id: str | None,
) -> Any | None:
if not assistant_id or assistant_id == _DEFAULT_ASSISTANT_ID:
return None
normalized = assistant_id.strip().lower().replace("_", "-")
try:
# The caller's config/context ``agent_name`` arrives here unvalidated.
if not isinstance(assistant_id, str):
raise ValueError("agent_name must be a string")
normalized = assistant_id.strip().lower().replace("_", "-")
return await asyncio.to_thread(
load_agent_config,
normalized,
@ -2194,7 +2202,11 @@ async def start_run(
run_metadata[DEERFLOW_ORIGIN_KEY] = origin
run_metadata[DEERFLOW_TRACE_METADATA_KEY] = ensure_trace_id()
config = build_run_config(thread_id, body.config, run_metadata, assistant_id=body.assistant_id)
try:
config = build_run_config(thread_id, body.config, run_metadata, assistant_id=body.assistant_id)
except ValueError as exc:
# Raised only for the caller's own config sections or assistant_id.
raise HTTPException(status_code=422, detail=str(exc)) from exc
if isinstance(config.get("metadata"), dict):
config["metadata"] = _admit_scheduler_metadata(config["metadata"], trusted=scheduled_task_runtime is not None)
config["metadata"].pop(DEERFLOW_ORIGIN_KEY, None)

View File

@ -339,6 +339,12 @@ does not accept this header and keeps the existing missing-stream close of
- `stream_resumable=false` is accepted: it is the LangGraph SDK's default and requests the non-resumable stream DeerFlow already serves
- Undeclared SDK options, including `checkpoint_during` and `durability`, also return `422` instead of being silently discarded
**Request Validation:**
- `config.configurable` and `config.context` must be objects or `null`; any other value returns `422` (`invalid_run_config_section`)
- An `assistant_id` that does not normalize to letters, digits and hyphens returns `422`, as does a non-string `agent_name` in `config.configurable`, `config.context` or `context`
- A message in `input.messages` that cannot be parsed returns `400` naming its index
- These checks run before a run is created
When outputs changed during the run, `run.delivery` events retain the Slice 1
facts (`presented`, `paths`, and `by_tool`) and add `produced_paths`,
`presented_paths`, `matched_paths`, plus an explicit verdict: `verification`,

View File

@ -762,6 +762,28 @@ def test_normalize_input_rejects_malformed_message_with_400():
assert "input.messages[1]" in excinfo.value.detail
@pytest.mark.parametrize(
"message",
[
{"role": "ai", "content": "", "tool_calls": "x"},
{"role": "ai", "content": "", "tool_calls": {"name": "t"}},
{"role": "ai", "content": "", "tool_calls": ["x"]},
{"lc": 1, "type": "constructor", "id": ["langchain", "schema", "messages", "AIMessage"], "kwargs": {"content": "", "tool_calls": "x"}},
],
ids=["string", "object", "string-items", "constructor"],
)
def test_normalize_input_rejects_wrong_typed_tool_calls_with_400(message):
"""LangChain's AI-message validator raises ``AttributeError`` for these."""
from fastapi import HTTPException
from app.gateway.services import normalize_input
with pytest.raises(HTTPException) as excinfo:
normalize_input({"messages": [{"role": "human", "content": "ok"}, message]})
assert excinfo.value.status_code == 400
assert excinfo.value.detail == "Invalid message at input.messages[1]"
def test_normalize_input_handles_trusted_internal_non_human_roles():
"""The previous implementation collapsed every role to HumanMessage with a
`# TODO: handle other message types` comment. Resuming a thread with prior
@ -4486,6 +4508,23 @@ def test_build_run_config_rejects_non_mapping_context():
build_run_config("thread-1", {"context": "bad-context"}, None)
@pytest.mark.parametrize("configurable", [[1], "ab", [["k", "v"]], []], ids=["list", "string", "pairs", "empty-list"])
@pytest.mark.parametrize("with_context", [False, True], ids=["configurable-only", "with-context"])
def test_build_run_config_rejects_non_mapping_configurable(configurable, with_context):
"""A non-object configurable is a clear ValueError, not a dict.update() error.
With ``context`` present the configurable section is otherwise ignored, but
reading its keys for the warning used to raise AttributeError.
"""
from app.gateway.services import build_run_config
request_config = {"configurable": configurable}
if with_context:
request_config["context"] = {}
with pytest.raises(ValueError, match="configurable"):
build_run_config("thread-1", request_config, None)
def test_build_run_config_null_context_custom_agent_injects_agent_name():
"""Custom assistant_id must be injected into both containers even when the
request started in context-only mode with ``context=null`` .

View File

@ -233,6 +233,40 @@ def test_run_request_rejects_unknown_sdk_options_with_exact_422(
}
@pytest.mark.parametrize("section", ["configurable", "context"])
@pytest.mark.parametrize("value", [[1], [["k", "v"]], [], "ab", 1, True], ids=["list", "pairs", "empty-list", "string", "int", "bool"])
def test_run_request_rejects_non_object_config_sections_with_exact_422(
client: TestClient,
section: str,
value: Any,
) -> None:
config = {section: value}
response = client.post("/api/runs/stream", json={"config": config})
assert response.status_code == 422
assert response.json() == {
"detail": [
{
"type": "invalid_run_config_section",
"loc": ["body", "config"],
"msg": f"config.{section} must be an object or null",
"input": config,
"ctx": {"section": section},
}
]
}
@pytest.mark.parametrize("section", ["configurable", "context"])
@pytest.mark.parametrize("value", [None, {}, {"model_name": "m"}], ids=["null", "empty", "object"])
def test_run_request_accepts_object_or_null_config_sections(section: str, value: Any) -> None:
from app.gateway.routers.thread_runs import RunCreateRequest
body = RunCreateRequest(config={section: value})
assert body.config == {section: value}
def test_openapi_stream_mode_enum_matches_runtime_support() -> None:
app = FastAPI()
app.include_router(runs.router)

View File

@ -567,6 +567,75 @@ def test_stream_run_completes_and_persists_runtime_state(isolated_app):
assert any(row["content"]["content"] == "Lifecycle complete." for row in message_events if row["event_type"] == "llm.ai.response")
_CONFIG_SECTION_ERROR = "invalid_run_config_section"
_UNRESOLVED_AGENT_DETAIL = "knowledge_scope assistant configuration could not be resolved"
@pytest.mark.parametrize(
("overrides", "status_code", "detail"),
[
pytest.param({"config": {"configurable": [1]}}, 422, _CONFIG_SECTION_ERROR, id="configurable-list"),
pytest.param({"config": {"configurable": "ab"}}, 422, _CONFIG_SECTION_ERROR, id="configurable-string"),
pytest.param({"config": {"configurable": [["k", "v"]]}}, 422, _CONFIG_SECTION_ERROR, id="configurable-pairs"),
pytest.param({"config": {"context": [1]}}, 422, _CONFIG_SECTION_ERROR, id="context-list"),
pytest.param({"config": {"context": "x", "configurable": [1]}}, 422, _CONFIG_SECTION_ERROR, id="both-sections"),
pytest.param(
{"assistant_id": "bad name!"},
422,
"Invalid assistant_id 'bad name!': must contain only letters, digits, and hyphens after normalization.",
id="assistant-id-characters",
),
pytest.param(
{"assistant_id": " "},
422,
"Invalid assistant_id ' ': must contain only letters, digits, and hyphens after normalization.",
id="assistant-id-blank",
),
pytest.param({"config": {"configurable": {"agent_name": 1}}}, 422, _UNRESOLVED_AGENT_DETAIL, id="configurable-agent-name"),
pytest.param({"config": {"context": {"agent_name": 1}}}, 422, _UNRESOLVED_AGENT_DETAIL, id="config-context-agent-name"),
pytest.param({"context": {"agent_name": 1}}, 422, _UNRESOLVED_AGENT_DETAIL, id="context-agent-name"),
pytest.param(
{"input": {"messages": [{"role": "user", "content": "hi"}, {"role": "ai", "content": "", "tool_calls": "x"}]}},
400,
"Invalid message at input.messages[1]",
id="tool-calls-string",
),
],
)
@pytest.mark.parametrize("route", ["thread", "stateless"])
def test_malformed_run_body_is_rejected_before_a_run_is_created(isolated_app, overrides, status_code, detail, route):
"""Bodies the request model accepts must still fail as client errors.
Each shape used to escape run admission as an unmapped exception and
answer 500. The thread route also proves nothing was admitted.
"""
from starlette.testclient import TestClient
controller = _RunController()
factory = _make_agent_factory(controller, title="Rejected", answer="never")
with (
patch("app.gateway.services.resolve_agent_factory", return_value=factory),
TestClient(isolated_app, raise_server_exceptions=False) as client,
):
csrf_token = _register_user(client)
thread_id = _create_thread(client, csrf_token)
path = f"/api/threads/{thread_id}/runs/wait" if route == "thread" else "/api/runs/wait"
response = client.post(path, json=_run_body(**overrides), headers={"X-CSRF-Token": csrf_token})
assert response.status_code == status_code, response.text
body = response.json()
if detail == _CONFIG_SECTION_ERROR:
assert [error["type"] for error in body["detail"]] == [_CONFIG_SECTION_ERROR]
else:
assert body == {"detail": detail}
assert not controller.started.is_set()
runs = client.get(f"/api/threads/{thread_id}/runs")
assert runs.status_code == 200, runs.text
assert runs.json() == []
def test_stream_run_executes_real_lead_agent_setup_agent_business_path(isolated_app, isolated_deer_flow_home: Path):
"""A runtime stream should execute real lead-agent business code and tools."""
from starlette.testclient import TestClient