feat(core): require an explicit user opt-in before the model launches a workflow (#9806)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run

* feat(core): require an explicit user opt-in before the model launches a workflow

The Workflow tool description carried judgement heuristics for orchestrating
well, but no rule about when not to orchestrate at all. Read on its own the
prose is encouragement, and a run that can dispatch up to the per-run agent
cap is a large spend to enter on inference rather than on a request.

Prepend a gate above the existing guidance: do not call the tool unless the
user asked for multi-agent orchestration, with the five forms that count as
asking under this project's own triggers -- the `workflow` keyword, the
user's own words, a skill or slash command, a named saved workflow reached
through `workflow('<name>')` or `scriptPath`, and a resume. Upstream's
`ultracode` marker is deliberately not among them: it does not exist here,
and naming it would enumerate a trigger no user can pull.

The fallback path is the load-bearing half. Without an offer-and-ask route
the model reads a refusal rule as "refuse", and a user who would have said
yes never gets asked, so the text ends by telling it to say what a workflow
would fan out over and let the user decide.

The agent cap is interpolated from `DEFAULT_MAX_AGENTS_PER_RUN` rather than
pasted, matching the rest of the description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDuRWMhKbqEYZXbbLhn3en

* chore: keep agent-core.ts as it is on main

The merge commit's pre-commit hook ran prettier over every file the merge
staged, not just the ones this branch changes, and reformatted a type union
in agent-core.ts. main's copy does not satisfy the repo's pinned prettier
3.6.1, so the hook produced a real diff in a file this PR has no business
touching. Restore main's bytes to keep the PR scoped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDuRWMhKbqEYZXbbLhn3en

* test(core): pin workflow opt-in contract

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
This commit is contained in:
qqqys 2026-08-24 17:39:04 +00:00 committed by GitHub
parent bf123a375a
commit 22bb5e8b9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 0 deletions

View file

@ -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

View file

@ -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.