mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
d0d66d62fa
commit
5bda01f524
10
CHANGELOG.md
10
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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`` .
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user