fix(workflows): align sequential Agent handoff size limits (#7231)

Allow ordinary Agent tasks the same 16,000 JSON bytes already accepted for
file-reference tasks, keeping the 12,000-byte output cap and bounded rejection.
Cover ASCII, escaped Unicode, maximum output and pre-admission rejection.

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Totoro 2026-10-21 19:02:26 +08:00 committed by GitHub
parent 4b7f2454fe
commit f6fe1e9c9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 49 additions and 2 deletions

View File

@ -209,6 +209,43 @@ async def test_sequential_parallel_branch_loop_structured_results(plugin):
assert final["logs"] == ["Checks finished"]
@pytest.mark.asyncio
@pytest.mark.parametrize("material", ["x" * 9000, "材料" * 750, "x" * 11998], ids=["ascii-report", "chinese-report", "maximum-output"])
async def test_step_output_can_be_handed_to_next_agent_without_truncation(plugin, material):
runs = plugin[3]
original_answer = runs.answer
async def answer(run, input):
await original_answer(run, input)
task = json.loads(input["messages"][0]["content"])
content = material if task == "prepare" else {"received": True}
runs.threads[run.thread_id]["values"]["messages"][-1]["content"] = json.dumps(content)
runs.answer = answer
launched = await launch(
plugin,
"""
const first = await agent({stepId:'prepare',agentRef:'lead_agent',task:'prepare'});
const second = await agent({stepId:'produce',agentRef:'lead_agent',task:{material:first.output,instruction:'Use the supplied material'}});
return second.output;
""",
)
final = await done(plugin, launched["run_id"])
assert final["status"] == "completed", final
assert final["result"] == {"received": True}
assert len(runs.starts) == 2
assert json.loads(runs.starts[1][1]["messages"][0]["content"]) == {"material": material, "instruction": "Use the supplied material"}
@pytest.mark.asyncio
async def test_oversized_agent_task_is_rejected_before_child_admission(plugin):
launched = await launch(plugin, "return await agent({stepId:'large',agentRef:'lead_agent',task:'x'.repeat(16001)});")
final = await done(plugin, launched["run_id"])
assert final["status"] == "failed"
assert not plugin[3].starts
assert not plugin[2].processes
@pytest.mark.asyncio
async def test_background_return_idempotency_and_owner_scope(plugin):
script = "return await agent({stepId:'a',agentRef:'lead_agent',task:'blocked'});"

View File

@ -8,6 +8,9 @@ Generated script runs only in worker.py's isolated, capability-free JS context.
Never pass host callables, capability handles, credentials or ambient environment
into it. Keep every IPC and stored/public payload bounded. Parent lifecycle must
kill and reap the worker and explicitly observe child cancellation.
Agent tasks allow 16,000 serialized JSON bytes and outputs 12,000; preserve enough
input room for a retained output plus task wrapping. Test sequential handoff with
both ASCII and escaped Unicode, and reject oversized inputs before admission.
The worker turn cap counts worker frames only; while the worker is idle (its job
queue drained) and Agent calls are pending, wait locally with bounded cancellation,
fault and worker-exit polls instead of round-tripping empty frames, so wall time stays the only clock bound.

View File

@ -115,6 +115,11 @@ API; validate verdicts in the script before branching. Every Agent call must be
awaited, including fan-out calls. Returning while child promises are outstanding
fails the workflow and interrupts tracked children.
Agent task inputs allow 16,000 serialized JSON bytes, including wrapping fields
and file references; step outputs allow 12,000. This leaves room to pass a saved
output to the next Agent with a short task instruction. JSON escaping counts
toward these limits. Larger material must use the file workflow described below.
`parallel()` takes lazy thunks and preserves order. The service, independently of
JavaScript promises, bounds admitted children per workflow and globally. A rejected
call rejects its promise; failures are not silently converted to null. Scripts may

View File

@ -23,6 +23,8 @@ from .store import TERMINAL, Store
NAMESPACE = "community.workflows"
FRAME = 48 * 1024
TURN_LIMIT = 10000
AGENT_TASK_BYTES = 16000
AGENT_OUTPUT_BYTES = 12000
logger = logging.getLogger(__name__)
@ -418,7 +420,7 @@ class Workflows(Recovery, Lifecycle, Artifacts):
{
"role": "user",
"id": message_id,
"content": encoded(task, 16000 if references else 8000),
"content": encoded(task, AGENT_TASK_BYTES),
}
]
}
@ -539,7 +541,7 @@ class Workflows(Recovery, Lifecycle, Artifacts):
output = content
else:
output = content
encoded(output, 12000)
encoded(output, AGENT_OUTPUT_BYTES)
# Evidence comes from the run's completed host assembly, never a
# fresh read of mutable configuration after the Agent finishes.
async with asyncio.timeout(5):