From 5bda01f524371477496b8f20cf701a749354eeb2 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Sat, 24 Oct 2026 11:12:05 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 10 +++ CHANGELOG_zh.md | 8 +++ backend/app/gateway/run_models.py | 19 ++++++ backend/app/gateway/services.py | 26 ++++++-- backend/docs/API.md | 6 ++ backend/tests/test_gateway_services.py | 39 +++++++++++ backend/tests/test_run_request_validation.py | 34 ++++++++++ backend/tests/test_runtime_lifecycle_e2e.py | 69 ++++++++++++++++++++ 8 files changed, 204 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a36c59860..c5fe496b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 067a400c5e..a31ff60fc9 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/backend/app/gateway/run_models.py b/backend/app/gateway/run_models.py index cc2aac570e..36353110cb 100644 --- a/backend/app/gateway/run_models.py +++ b/backend/app/gateway/run_models.py @@ -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.""" diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index ee22d52ca1..e8643e8814 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -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) diff --git a/backend/docs/API.md b/backend/docs/API.md index db80fbff12..6a35213d00 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -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`, diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index 71facbd9ba..b695cfe1de 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -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`` . diff --git a/backend/tests/test_run_request_validation.py b/backend/tests/test_run_request_validation.py index 805cee7ba0..2da24fe9b2 100644 --- a/backend/tests/test_run_request_validation.py +++ b/backend/tests/test_run_request_validation.py @@ -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) diff --git a/backend/tests/test_runtime_lifecycle_e2e.py b/backend/tests/test_runtime_lifecycle_e2e.py index 730c5a2475..ae62951694 100644 --- a/backend/tests/test_runtime_lifecycle_e2e.py +++ b/backend/tests/test_runtime_lifecycle_e2e.py @@ -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