From f6fe1e9c9e029bf22b8e2eb8e2e23e1258cf0814 Mon Sep 17 00:00:00 2001 From: Totoro Date: Wed, 21 Oct 2026 19:02:26 +0800 Subject: [PATCH] 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 --- backend/tests/test_workflow_plugin.py | 37 +++++++++++++++++++ .../deerflow-extension-workflows/AGENTS.md | 3 ++ .../deerflow-extension-workflows/README.md | 5 +++ .../deerflow_extension_workflows/service.py | 6 ++- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_workflow_plugin.py b/backend/tests/test_workflow_plugin.py index 843ada6b46..96e68386bc 100644 --- a/backend/tests/test_workflow_plugin.py +++ b/backend/tests/test_workflow_plugin.py @@ -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'});" diff --git a/examples/deerflow-extension-workflows/AGENTS.md b/examples/deerflow-extension-workflows/AGENTS.md index 54abb5f49c..456e0a93f8 100644 --- a/examples/deerflow-extension-workflows/AGENTS.md +++ b/examples/deerflow-extension-workflows/AGENTS.md @@ -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. diff --git a/examples/deerflow-extension-workflows/README.md b/examples/deerflow-extension-workflows/README.md index 0ce8ca60a3..7fb1c5d1a8 100644 --- a/examples/deerflow-extension-workflows/README.md +++ b/examples/deerflow-extension-workflows/README.md @@ -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 diff --git a/examples/deerflow-extension-workflows/deerflow_extension_workflows/service.py b/examples/deerflow-extension-workflows/deerflow_extension_workflows/service.py index 173cf55910..2ceb37c5cb 100644 --- a/examples/deerflow-extension-workflows/deerflow_extension_workflows/service.py +++ b/examples/deerflow-extension-workflows/deerflow_extension_workflows/service.py @@ -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):