Repository navigation
fix(extensions): measure the team response size gate in UTF-8 bytes - #6274
WillemJiang merged 2 commits into
Conversation
json.dumps escapes non-ASCII by default, so a CJK clarification answer counted ~6 escaped bytes per character and an emoji 12, while the plugin documents 4,000 characters / 8,000 UTF-8 bytes and its own UI validates exactly those two bounds. A 漢 answer of 2,000 characters (6,000 UTF-8 bytes, inside the bound) was refused by the gate. Serialize with ensure_ascii=False so the gate measures the UTF-8 bytes it names, matching the budget walkers in the same module.
willem-bd
left a comment
There was a problem hiding this comment.
Correct, well-motivated fix with a genuine red-on-main/green-here test split, and it matches the module's existing ensure_ascii=False convention for byte measurement. Two residual, non-blocking points on the gate line: the 2-byte JSON envelope still cuts a narrow band out of the documented 8,000-byte ceiling (suggestion), and unpaired-surrogate input now fails with a codec message instead of the friendly size error (nit).
| async def resume(self, payload, context): | ||
| fields(payload, "team_id job_id response request_id") | ||
| if payload["response"] is None or len(json.dumps(payload["response"]).encode()) > 8000: | ||
| if payload["response"] is None or len(json.dumps(payload["response"], ensure_ascii=False).encode()) > 8000: |
There was a problem hiding this comment.
The change is right, but a narrow band of the original UI/server divergence survives: the gate still counts the two JSON envelope quotes, so a plain-string answer of 7,999-8,000 UTF-8 bytes serializes to 8,001-8,002 counted bytes and is refused, while the UI (static/index.mjs, inputText: new TextEncoder().encode(value).length > limit * 2) accepts up to exactly 8,000. An answer at the documented ceiling still passes UI validation and lands the job back in waiting_input. Since the documented answer case is always a string, consider measuring it directly and reserving the serialized envelope for non-strings, e.g. size = len(payload["response"].encode()) if isinstance(payload["response"], str) else len(json.dumps(payload["response"], ensure_ascii=False).encode()), plus a 7,999/8,000/8,001 boundary test to pin whichever semantics you choose.
| fields(payload, "team_id job_id response request_id") | ||
| if payload["response"] is None or len(json.dumps(payload["response"]).encode()) > 8000: | ||
| if payload["response"] is None or len(json.dumps(payload["response"], ensure_ascii=False).encode()) > 8000: | ||
| raise ValueError("An explicit response of at most 8 KiB is required") |
There was a problem hiding this comment.
Related edge: with ensure_ascii=False, a response containing an unpaired surrogate (JSON "\ud800" — decodable by Python's json.loads) now raises UnicodeEncodeError from .encode() before the size comparison, so the caller gets a codec message instead of this friendly "8 KiB" error. It is still a ValueError subclass, so API error handling is unchanged; wrapping the measurement or pre-validating would keep the message uniform for malformed input.
Measure string answers without JSON quotes or escapes, retain the structured JSON byte limit, and reject malformed Unicode with the normal response validation error. Add boundary and state-preservation regressions and document the response budgets.
Fixes #6273
Why
The trigger here is the team
collaboration plugin that landed on
maina few hours ago (#6243). Itsresumeactionsizes a clarification answer with
len(json.dumps(response).encode())(
examples/deerflow-extension-agent-teams/deerflow_extension_agent_teams/service.py:321),and
json.dumpsescapes non-ASCII by default: a漢becomes the six ASCII bytes\u6f22, and an astral character such as🙂becomes twelve. The gate then compares thatescaped length against 8,000, so it enforces roughly a third to a half of the bound the
plugin documents and validates in its own UI.
The pain is user-facing and specific to the language the answer is written in. The README
promises that "tasks and clarification answers accept up to 4,000 characters / 8,000 UTF-8
bytes" and that "the UI validates both bounds before submission and preserves oversized
text for editing" (
README.md:107-112), andstatic/index.mjsdoes exactly that —inputTextrejects onlyArray.from(value).length > 4000ornew TextEncoder().encode(value).length > 8000(:29-33, applied to the answer at:802-804). A Chinese answer between 1,334 and 2,666 characters is inside both of thosebounds, is accepted by the form, is refused by the server, and leaves the job in
waiting_input. The measured divergence:漢× 1,333漢× 1,334漢× 2,000漢× 2,666漢× 2,667🙂× 1,000The bound is also not something the user can work towards, because the number in the
refusal ("at most 8 KiB") describes escaped JSON rather than the text in the box.
What changed
A clarification answer (and any JSON response to a confirmation request) is now measured
in UTF-8 bytes, which is the unit the error message, the README and the UI all name. Callers
sending non-ASCII answers regain the documented ceiling: up to 2,666 CJK characters or
4,000 characters of any mix that fits in 8,000 UTF-8 bytes, instead of 1,333 CJK
characters. Answers that genuinely exceed 8,000 UTF-8 bytes are still refused, unchanged.
No storage shape, no API shape and no other bound changes.
ensure_ascii=Falsewas already this module's convention for every site that measuresserialized bytes — the 24 KiB shared-context budget walker (
service.py:47), the revisionhash (
:203), the routing prompt (:445) and the receipt payload (:538) all pass it,and the 6,000-byte final-answer cap measures
answer.encode()directly (:518). Line 321was the one exception, so this also makes the module internally consistent. Tasks — which
the README documents with the identical 4,000 / 8,000 pair — were already checked against
real UTF-8 bytes by the module's own
text()helper; only answers diverged.Surface area
resumebackend action underexamples/deerflow-extension-agent-teamsinside the documented bound) are now accepted; nothing that was accepted becomes refused,
except in a 2-byte boundary band described under "Bug fix verification"
Screenshots / Recording
Not applicable — no frontend file is touched.
Bug fix verification
Test path:
backend/tests/test_agent_teams_plugin.py.It goes red on
mainand green on this branch. Measured at5c3329afby checking out theupstream
service.pyunder the new tests, then restoring:The two red cases are
…[cjk-2000-chars]and…[emoji-1000-chars], both failing withValueError: An explicit response of at most 8 KiB is requiredatservice.py:322. Thethree green cases in the same run are the control group: 4,000 ASCII characters (accepted
both before and after), and the two oversized answers — 3,000
漢(9,000 UTF-8 bytes) anda JSON object with a 9,000-byte string — which are refused both before and after, so the
change cannot be credited with simply disabling the gate.
resumereaches the gate before any other bound. On the host-interrupt path the job isparked in
waiting_inputwithclarification = None(service.py:482-484), socontrol()never applies the module'stext()helper and line 321 is the only size checkthe answer faces; the new
interrupted_jobhelper parks a job that way deliberately, sothe tests measure the gate rather than an unrelated check. On the team-clarification path
control()does applytext(payload, "response"), which compareslen(value.encode())against 8,000 and would accept the same answer — but the escaped gate refuses it one line
earlier, which is exactly the divergence the UI cannot warn about.
One honest boundary residual:
json.dumpsstill adds the two quote bytes of the JSONenvelope, so an answer of exactly 8,000 UTF-8 bytes is refused by the fixed gate
(8,002 counted). The largest CJK answer that passes is therefore 2,666 characters
(7,998 bytes), which is still the number the bound implies, since 2,667 characters is
8,001 bytes and genuinely over. Tightening the comparison to the envelope, or rewording
the message to say "8 KiB of serialized JSON", is a one-line follow-up if a maintainer
prefers the bound stated the other way.
Validation
Run on this branch at
5c3329af, Python 3.12.11:make lint && make testfrombackend/was not run: this Windows machine has nomake,so the ruff commands above are the same tools invoked through the venv directly, limited to
the two changed files. The diff touches only
examples/deerflow-extension-agent-teams/**and its backend test file; no file under
backend/appis modified.Diff is 2 files, +35 −1: one argument at the call site, one helper and two parametrized
tests.
AI assistance
Tool(s) used: Qoder (AI coding agent)
How you used it: The agent traced the UI/server divergence, wrote the failing tests,
applied the
ensure_ascii=Falsechange and ran every command above; the RED numbers weremeasured by restoring the upstream
service.pyunder the new tests and re-running them.it's not unreviewed AI output.
Left unticked deliberately: an agent wrote and measured this diff, so the box is for the
account holder to tick after their own read.
Agent-assisted contribution, disclosed per
AGENTS.md.