fix(uploads): never let a malformed files[*].size abort the run (#5855)

* fix(uploads): never let a malformed files[*].size abort the run

UploadsMiddleware._files_from_kwargs validates every field of a
client-supplied file entry fail-soft — non-dict entries, non-basename or
staging filenames, and files missing from the uploads dir are all skipped —
except `size`, which went straight to int(). A value int() cannot convert
("abc", a list, inf) raised ValueError/TypeError/OverflowError out of
before_agent (and the executor-wrapped abefore_agent), failing the whole run
before the model was called. The Gateway strips only server-owned metadata
keys and edit/regenerate deep-copies `files` verbatim, so the same entry
failed again on every retry of that message.

The size only feeds the human-readable "(N KB)" line in <current_uploads>,
so route it through _coerce_file_size: a non-negative finite number (or a
numeric string such as "2048") is kept as int; None, bools, negatives,
non-finite floats and anything unconvertible fall back to 0, the value a
missing size already gets.

Tests pin the fallback for each unusable shape, the kept values, and that
before_agent still injects an existing upload whose size is malformed.

* docs(changelog): reference #5855 in the uploads size entries

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-09-25 23:35:28 +09:00 committed by GitHub
parent 8121dbcb57
commit bbab1e84d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 78 additions and 2 deletions

View File

@ -2404,6 +2404,14 @@ This release closes that milestone with **772 merged pull requests**.
### Fixed
- **uploads:** A malformed `files[*].size` in a run's message metadata no
longer fails the whole run. `UploadsMiddleware` validated every other field
of a client-supplied file entry fail-soft but passed `size` straight to
`int()`, so a value such as `"abc"` or a list raised out of `before_agent`
before the model was called — and again on every edit or regenerate of that
message, since the entry is carried over verbatim. The size only feeds the
human-readable line in `<current_uploads>`; unusable values now fall back to
`0`, the same as a missing size, while numeric strings keep working. ([#5855])
- **release:** Bumping the version no longer leaves `backend/uv.lock` behind.
`scripts/bump_version.sh` rewrote `backend/pyproject.toml`, `frontend/package.json`
and the Helm chart, but the lockfile records the root package's own version too
@ -6077,5 +6085,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#5844]: https://github.com/bytedance/deer-flow/pull/5844
[#5845]: https://github.com/bytedance/deer-flow/pull/5845
[#5848]: https://github.com/bytedance/deer-flow/pull/5848
[#5855]: https://github.com/bytedance/deer-flow/pull/5855
[#5859]: https://github.com/bytedance/deer-flow/pull/5859

View File

@ -2120,6 +2120,11 @@
### 修复
- **上传:** 运行消息元数据中格式错误的 `files[*].size` 不再导致整个运行失败。
`UploadsMiddleware` 对客户端提供的文件条目的其他字段都做了容错校验,唯独把 `size` 直接交给
`int()`,因此 `"abc"` 或列表这样的值会在调用模型之前从 `before_agent` 抛出——而且由于该条目
会被原样带入,之后每次编辑或重新生成这条消息都会再次失败。该字段只用于 `<current_uploads>`
里的可读大小;现在无法使用的值会回退为 `0`,与缺失时一致,数字字符串仍然有效。([#5855])
- **发布:** 版本升级不再把 `backend/uv.lock` 落下。`scripts/bump_version.sh` 会改写
`backend/pyproject.toml`、`frontend/package.json` 与 Helm chart,但 lockfile 同样记录了
根包自身的版本(uv 保留其 PEP 440 形式,因此 `2.1.0-rc0` 存为 `2.1.0rc0`),于是文档给出的
@ -5175,4 +5180,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#5844]: https://github.com/bytedance/deer-flow/pull/5844
[#5845]: https://github.com/bytedance/deer-flow/pull/5845
[#5848]: https://github.com/bytedance/deer-flow/pull/5848
[#5855]: https://github.com/bytedance/deer-flow/pull/5855
[#5859]: https://github.com/bytedance/deer-flow/pull/5859

View File

@ -67,7 +67,7 @@ strict providers reject.
4. **PiiRedactionMiddleware** - *(optional, `pii_redaction.enabled`, default off, #3190)* Rewrites PII in genuine user messages (`wrap_model_call`) and remote-content tool results (`wrap_tool_call`, the tool-result allowlist) to irreversible value-derived placeholders. Deterministic regex detectors only. Innermost Layer-1 wrapper; compaction input, summaries, title input and queued memory payloads are redacted via `redact_text`.
5. **ThreadDataMiddleware** - Creates per-thread directories under the user's isolation scope (`backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`); resolves identity via `resolve_runtime_user_id(runtime)`, including Gateway runtime context and standalone LangGraph Server auth, then falls back to the request ContextVar / `"default"`
6. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation (lead agent only); upload existence checks use the same runtime-resolved user bucket as thread-data creation
6. **UploadsMiddleware** - Tracks and injects newly uploaded files into conversation (lead agent only); upload existence checks use the same runtime-resolved user bucket as thread-data creation; a malformed client-supplied `files[*].size` is coerced to `0` (like a missing one), never raised
7. **SandboxMiddleware** - Acquires sandbox, stores `sandbox_id` in state. The
lead runtime normally owns the thread's physical Agent-skill projection;
delegated subagents and the prompt-only bootstrap agent are non-owners, so

View File

@ -160,6 +160,25 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
return "\n".join(lines)
@staticmethod
def _coerce_file_size(value: object) -> int:
"""Best-effort byte count from a client-supplied ``files[*].size``.
The value only feeds the human-readable size in ``<current_uploads>``,
and every other field in ``_files_from_kwargs`` is validated fail-soft,
so an unusable size must never abort the run: anything that is not a
non-negative finite number (``"abc"``, a list, a bool, ``-5``, ``inf``)
falls back to ``0``, the same value a missing size gets. Numeric
strings such as ``"2048"`` keep working.
"""
if value is None or isinstance(value, bool):
return 0
try:
size = int(value)
except (TypeError, ValueError, OverflowError):
return 0
return size if size >= 0 else 0
def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None:
"""Extract file info from message additional_kwargs.files.
@ -191,7 +210,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
files.append(
{
"filename": filename,
"size": int(f.get("size") or 0),
"size": self._coerce_file_size(f.get("size")),
"path": f"/mnt/user-data/uploads/{filename}",
"extension": Path(filename).suffix,
}

View File

@ -7,10 +7,12 @@ Covers:
additional_kwargs, historical files from uploads dir, edge-cases)
"""
import math
import re
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from deerflow.agents.middlewares.uploads_middleware import UploadsMiddleware
@ -159,6 +161,30 @@ class TestFilesFromKwargs:
assert result is not None
assert result[0]["size"] == 0
@pytest.mark.parametrize(
"size",
["abc", "12.5", "", [1], {"n": 1}, None, True, -5, math.inf, math.nan],
ids=["word", "decimal-string", "empty-string", "list", "dict", "none", "bool", "negative", "inf", "nan"],
)
def test_unusable_size_falls_back_to_zero_instead_of_raising(self, tmp_path, size):
"""``files[*].size`` is client-supplied display metadata; every other field in this loop is validated fail-soft."""
mw = _middleware(tmp_path)
msg = _human("hi", files=[{"filename": "f.txt", "size": size, "path": "/mnt/user-data/uploads/f.txt"}])
result = mw._files_from_kwargs(msg)
assert result is not None
assert result[0]["filename"] == "f.txt"
assert result[0]["size"] == 0
assert type(result[0]["size"]) is int
@pytest.mark.parametrize("size, expected", [(2048, 2048), ("2048", 2048), (2048.0, 2048), (" 2048 ", 2048)], ids=["int", "string", "float", "padded-string"])
def test_usable_size_is_kept(self, tmp_path, size, expected):
mw = _middleware(tmp_path)
msg = _human("hi", files=[{"filename": "f.txt", "size": size, "path": "/mnt/user-data/uploads/f.txt"}])
result = mw._files_from_kwargs(msg)
assert result is not None
assert result[0]["size"] == expected
assert type(result[0]["size"]) is int
def test_skips_upload_staging_filenames(self, tmp_path):
mw = _middleware(tmp_path)
msg = _human("hi", files=[{"filename": ".upload-active.part", "size": 5, "path": "/mnt/user-data/uploads/.upload-active.part"}])
@ -249,6 +275,22 @@ class TestBeforeAgent:
result = mw.before_agent(state, _runtime())
assert result == {"uploaded_files": []}
def test_unusable_size_does_not_fail_the_run(self, tmp_path):
"""A malformed size on an existing upload must still inject the file, not abort before the model is called."""
mw = _middleware(tmp_path)
uploads_dir = _uploads_dir(tmp_path)
(uploads_dir / "present.txt").write_text("hello", encoding="utf-8")
msg = _human("hi", files=[{"filename": "present.txt", "size": "abc", "path": "/mnt/user-data/uploads/present.txt"}])
state = self._state(msg)
result = mw.before_agent(state, _runtime())
assert result is not None
assert [f["filename"] for f in result["uploaded_files"]] == ["present.txt"]
assert result["uploaded_files"][0]["size"] == 0
block = _current_uploads_block(result["messages"][0].content)
assert "present.txt" in block
def test_clears_uploaded_files_when_all_files_missing_from_disk(self, tmp_path):
mw = _middleware(tmp_path)
_uploads_dir(tmp_path) # directory exists but is empty