Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/core/src/tools/workflow/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Comment on lines +155 to +156

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c25fca4. The gate test now pins the any-of connective exactly. Independent mutation reproduction confirmed that changing any to all previously left the focused suite green and now fails. Verification: workflow.test.ts 53/53; npm run build; npm run typecheck; targeted ESLint and Prettier; pre-commit hook.

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/);
Comment on lines +176 to +177

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c25fca4. The existing gate test now pins both prohibition sentences exactly. Independent mutation reproduction confirmed that deleting them previously left the focused suite green and now fails. Verification: workflow.test.ts 53/53; npm run build; npm run typecheck; targeted ESLint and Prettier; pre-commit hook.


// 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
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/tools/workflow/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<name>')\` 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.
Expand Down
Loading