fix(extensions): measure the team response size gate in UTF-8 bytes (#6274)

* fix(extensions): measure the team response size gate in UTF-8 bytes

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.

* fix(agent-teams): honor the raw UTF-8 response boundary

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.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Grapette.L 2026-10-05 19:45:54 +08:00 committed by GitHub
parent 7d50f3fc7b
commit d67a9e4d74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 122 additions and 3 deletions

View File

@ -1,6 +1,7 @@
"""Team extension contracts exercised through the installed contribution."""
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
@ -943,3 +944,107 @@ async def test_cancel_reconciles_a_clarification_response_with_lost_admission_ac
view = await actions["get"]({"team_id": team["id"]}, context(runs))
assert view["jobs"][0]["status"] == "cancelled"
assert len(view["jobs"]) == 1 and len(runs.starts) == 2
async def waiting_job(actions, service, runs, *, clarification=False):
"""Park a job for either an explicit interrupt or an ordinary clarification."""
team = await create(actions, runs)
sent = await actions["send"]({"team_id": team["id"], "member_id": team["members"][0]["id"], "text": "Check release", "request_id": "one"}, context(runs))
await service.tick()
runs.finish(sent["id"], interrupted=not clarification)
if clarification:
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
request = SimpleNamespace(tool_call={"name": "ask_clarification", "id": "question", "args": {"question": "Which environment?", "clarification_type": "missing_info"}}, runtime=None)
command = ClarificationMiddleware().wrap_tool_call(request, lambda _: pytest.fail("intercept clarification"))
runs.threads[team["members"][0]["thread_id"]]["values"]["messages"].extend(m.model_dump() for m in command.update["messages"])
await service.tick()
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "waiting_input"
return team, sent
@pytest.mark.asyncio
@pytest.mark.parametrize(
"answer",
[
pytest.param("漢" * 2000, id="cjk-2000-chars"),
pytest.param("🙂" * 1000, id="emoji-1000-chars"),
pytest.param("a" * 4000, id="ascii-4000-chars"),
pytest.param('"' * 4000, id="json-quotes"),
pytest.param("\n" * 3999 + "a", id="json-newlines"),
],
)
async def test_a_clarification_answer_within_the_documented_bounds_is_accepted(plugin, answer):
# README: tasks and clarification answers accept up to 4,000 characters / 8,000 UTF-8 bytes,
# and the UI validates exactly those two bounds before submitting.
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}, context(runs))
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "resuming"
@pytest.mark.asyncio
@pytest.mark.parametrize("answer", ["漢" * 3000, {"text": "x" * 9000}])
async def test_an_oversized_clarification_answer_is_still_refused(plugin, answer):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
with pytest.raises(ValueError, match="8 KiB"):
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}, context(runs))
assert (await actions["get"]({"team_id": team["id"]}, context(runs)))["jobs"][0]["status"] == "waiting_input"
@pytest.mark.asyncio
@pytest.mark.parametrize("clarification", [False, True], ids=["interrupt", "clarification"])
@pytest.mark.parametrize("size", [7999, 8000, 8001])
async def test_string_response_uses_the_raw_utf8_byte_boundary(plugin, clarification, size):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs, clarification=clarification)
answer = "漢" * 2666 + "a" * (size - 7998)
payload = {"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}
if size > 8000:
with pytest.raises(ValueError, match="8 KiB"):
await actions["resume"](payload, context(runs))
else:
await actions["resume"](payload, context(runs))
job = (await service.db("get", "alice", team["id"]))["jobs"][0]
assert job["status"] == ("waiting_input" if size > 8000 else "resuming")
if size <= 8000:
assert job["resume"]["response"] == answer
if clarification:
assert job["resume"]["input"]["messages"][0]["content"] == answer
@pytest.mark.asyncio
@pytest.mark.parametrize("size", [7999, 8000, 8001])
async def test_structured_response_keeps_the_utf8_json_byte_boundary(plugin, size):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
# The JSON object adds 12 bytes around its string value.
answer = {"text": "漢" * 2660 + "a" * (size - 7980 - 12)}
payload = {"team_id": team["id"], "job_id": sent["id"], "response": answer, "request_id": "answer"}
if size > 8000:
with pytest.raises(ValueError, match="8 KiB"):
await actions["resume"](payload, context(runs))
else:
await actions["resume"](payload, context(runs))
job = (await service.db("get", "alice", team["id"]))["jobs"][0]
assert job["status"] == ("waiting_input" if size > 8000 else "resuming")
if size <= 8000:
assert job["resume"]["response"] == answer
@pytest.mark.asyncio
@pytest.mark.parametrize("response_json", ['"\\ud800"', '"\\udc00"', '{"text": "\\ud800"}', '{"\\udc00": true}'])
async def test_malformed_unicode_response_uses_the_friendly_validation_error(plugin, response_json):
_, actions, service = plugin
runs = Runs()
team, sent = await waiting_job(actions, service, runs)
with pytest.raises(ValueError, match="^An explicit response of at most 8 KiB is required$"):
await actions["resume"]({"team_id": team["id"], "job_id": sent["id"], "response": json.loads(response_json), "request_id": "answer"}, context(runs))
job = (await service.db("get", "alice", team["id"]))["jobs"][0]
assert job["status"] == "waiting_input"
assert job["resume"] is None

View File

@ -27,6 +27,8 @@ backend character/UTF-8 byte limits. Use host theme tokens for action colors.
Validate user-entered team names, goals, tasks and clarification answers against
both backend bounds without truncating the draft. Native mention labels must fit
the host's 120 UTF-16-unit limit; keep routing IDs independent of display labels.
Measure string responses as raw UTF-8 and structured interrupt responses as UTF-8
JSON. Reject malformed Unicode before changing a waiting job or admitting a run.
Ordinary human-input artifacts end runs successfully without a graph interrupt.
Keep unanswered requests waiting; persist text-response inputs and idempotency

View File

@ -109,7 +109,11 @@ own teams. Sharing a host conversation does **not** share its team's private dat
- Team names accept up to 80 characters / 160 UTF-8 bytes; goals accept up to
2,000 characters / 4,000 UTF-8 bytes. Tasks and clarification answers accept
up to 4,000 characters / 8,000 UTF-8 bytes. The UI validates both bounds before
submission and preserves oversized text for editing. Native mention labels
submission and preserves oversized text for editing. String answers count their
raw UTF-8 bytes, including the 8,000-byte ceiling; JSON quotes and escapes do not
reduce that allowance. Structured graph-interrupt responses retain an 8,000-byte
UTF-8 JSON limit. Responses with unpaired Unicode surrogates are rejected through
the normal response validation. Native mention labels
fall back to the member name when the combined team/member label exceeds the
host's 120 UTF-16-unit limit; selection still routes by team/member IDs.
Shared context contains
@ -151,7 +155,9 @@ creation), request capacity independent of receipts, ambiguous admission, and
lifecycle locking. Host-run transport is controlled for deterministic tests.
Clarification tests execute the host's real tool and middleware in an Agent graph,
including repeated questions, peer/mention/result-receipt paths, lost response
acknowledgements and restart. Queued-request regressions pin handoff attribution
acknowledgements and restart. Response regressions cover the 7,999/8,000/8,001-byte
boundaries for both input paths, structured JSON, escaping and malformed Unicode.
Queued-request regressions pin handoff attribution
and the chain limit while the first request is running or awaiting admission acknowledgement.
For a browser check, install the repo's frontend dependencies, then start the

View File

@ -318,7 +318,13 @@ class Teams:
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:
response = payload["response"]
try:
content = response if isinstance(response, str) else json.dumps(response, ensure_ascii=False)
valid = response is not None and len(content.encode("utf-8")) <= 8000
except UnicodeEncodeError:
valid = False
if not valid:
raise ValueError("An explicit response of at most 8 KiB is required")
text(payload, "request_id", 128)
return await self.control(payload, context, cancel=False)