diff --git a/packages/core/src/tools/workflow/workflow.test.ts b/packages/core/src/tools/workflow/workflow.test.ts index 9a22498b62..92b7ec5711 100644 --- a/packages/core/src/tools/workflow/workflow.test.ts +++ b/packages/core/src/tools/workflow/workflow.test.ts @@ -132,6 +132,57 @@ describe('WorkflowTool', () => { expect(description).toContain('.qwen/workflows'); }); + // The policy prose above tells the model how to orchestrate *well*; on its + // own it reads as encouragement, and the model fans out on tasks nobody + // asked to spend a fleet on. This gate is the half that says when not to. + it('description gates the tool on an explicit user request', () => { + const { description } = new WorkflowTool(fakeConfig()); + + // Ordering is the point, not just presence: a gate placed after the + // "what a workflow is for" pitch reads as a footnote to it. It has to + // come first, so it frames everything below rather than qualifying it. + const gate = description.indexOf('**Only on an explicit request**'); + const pitch = description.indexOf('**What a workflow is for**'); + expect(gate).toBeGreaterThanOrEqual(0); + expect(gate).toBeLessThan(pitch); + + // Each enumerated form is a real qwen trigger. Without the list the gate + // is unfalsifiable from the model's side — it cannot tell whether the + // request in front of it qualifies. + expect(description).toContain( + 'It counts as requested when any of these holds:', + ); + expect(description).toMatch(/contains the word `workflow`/); + expect(description).toMatch(/in their own words/); + expect(description).toMatch(/skill or slash command/); + expect(description).toMatch(/named a saved workflow/); + expect(description).toMatch(/resume or continue an earlier run/); + + // Upstream's marker for this is `ultracode`, which does not exist here: + // naming it would enumerate a trigger no qwen user can pull, and the + // gate would refuse work that a real trigger should have allowed. + expect(description).not.toMatch(/ultracode/i); + + // The load-bearing half of the gate. Without an offer-and-ask path the + // model reads "do not call it" as "refuse", and a user who would have + // said yes never gets asked. Over-blocking is this change's one real + // failure mode, so the escape hatch is anchored. + expect(description).toContain( + 'Do not call this tool unless the user has asked for multi-agent orchestration.', + ); + expect(description).toContain( + 'Otherwise do not call it, however well the task would parallelize.', + ); + expect(description).toMatch(/let the user decide/); + expect(description).toMatch(/skips the ask/); + + // Interpolated, not pasted: the gate justifies itself with the fleet + // size, so a raised cap has to move this sentence too. + expect(description).toContain( + `dispatch up to ${DEFAULT_MAX_AGENTS_PER_RUN} subagents`, + ); + }); + // ── Approval dialog ──────────────────────────────────────────────────── // // What the user is asked to approve is arbitrary model-authored JavaScript diff --git a/packages/core/src/tools/workflow/workflow.ts b/packages/core/src/tools/workflow/workflow.ts index 2b12d98814..51197ce926 100644 --- a/packages/core/src/tools/workflow/workflow.ts +++ b/packages/core/src/tools/workflow/workflow.ts @@ -809,6 +809,18 @@ function safeStringifyDisplayPayload(payload: unknown): string { */ const WORKFLOW_TOOL_DESCRIPTION = `Execute a workflow script that orchestrates subagents deterministically. +**Only on an explicit request** + +Do not call this tool unless the user has asked for multi-agent orchestration. A run can dispatch up to ${DEFAULT_MAX_AGENTS_PER_RUN} subagents and spend tokens accordingly, so that scale has to be requested rather than inferred. It counts as requested when any of these holds: + +- The user's message contains the word \`workflow\`; a system reminder confirms it when it does. +- The user asked for orchestration in their own words — run a workflow, fan out agents, orchestrate this with subagents. +- A skill or slash command the user invoked instructs you to use this tool. +- The user named a saved workflow to run, reached through \`workflow('')\` or \`scriptPath\`. +- The user asked to resume or continue an earlier run, which is \`resumeFromRunId\`. + +Otherwise do not call it, however well the task would parallelize. Do the work in the main loop, or spawn a single subagent for one self-contained piece. When a workflow would genuinely be the better tool, say in one sentence what it would fan out over and roughly how many agents that is, then let the user decide — and mention that including the word \`workflow\` next time skips the ask. + **What a workflow is for** Reach for one to be comprehensive (decompose the work and cover every part in parallel), to be confident (independent perspectives and adversarial checks before an answer is committed to), or to take on scale a single context cannot hold — migrations, audits, broad sweeps. The script is where that structure is encoded: what fans out, what verifies, what synthesizes. Parallelism on its own is not a reason; work that is already one short sequence of edits belongs in the main loop.