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