mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(core): Workflow tool P1 — minimal node:vm sandbox + sequential agent() (#4721) (#4732)
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(core): register Workflow tool name (P1)
* feat(core): isWorkflowsEnabled config gate with env-var override (P1)
* feat(core): stripExportMeta helper for workflow sandbox (P1)
* feat(core): createWorkflowSandbox with determinism stubs (P1)
* test(core): cover workflow sandbox phase/log/agent primitives (P1)
* feat(core): WorkflowOrchestrator with injectable dispatch (P1)
* feat(core): WorkflowOrchestrator production dispatch via AgentHeadless (P1)
* feat(core): WorkflowTool wraps WorkflowOrchestrator (P1)
* feat(core): register WorkflowTool behind isWorkflowsEnabled gate (P1)
* feat(core): export WorkflowTool from package index (P1)
* fix(core): harden workflow sandbox + tighten agent() opts surface (P1)
SEC-C1: deep-null-proto + hardenClosure blocks args/closure realm-escape PoC.
SEC-C2: vm 30s timeout kills sync infinite loops.
UP-C1: agent() throws on unsupported opts (schema/isolation/model/agentType).
UP-I1: keep verbatim subagent system prompt comment.
ARCH-C1: thread AbortSignal into buildProductionDispatch → subagent.execute().
UP-C2: llmContent carries script result verbatim; metadata moves to returnDisplay.
SEC-I1: add WORKFLOW to EXCLUDED_TOOLS_FOR_SUBAGENTS to prevent recursive fan-out.
SEC-I2: cap logs[] at 10 000 lines with a truncation marker.
REUSE-I1: use ToolErrorType.EXECUTION_FAILED in workflow error returns.
TST: add security PoC tests, unique-runId, dispatch-rejection, llmContent-unwrap.
TST-I1: remove setter/getter tautology test from config.workflows.test.ts.
* refactor(core): decouple WorkflowOrchestrator from Config (P1)
- Extract WORKFLOW_SUBAGENT_SYSTEM_PROMPT into workflow-prompts.ts
- Lift buildProductionDispatch() into exported createProductionDispatch(config, signal?)
- WorkflowOrchestrator constructor now takes dispatch directly: (dispatch: WorkflowAgentDispatch)
- Remove WorkflowOrchestratorOptions interface
- WorkflowToolOptions.orchestratorOverrides replaced by WorkflowToolOptions.dispatch
- WorkflowToolInvocation.execute() calls createProductionDispatch() when no override is set
- Tests updated: orchestrator tests inject dispatch directly; production-dispatch tests moved to createProductionDispatch describe block
* fix(core): Math proxy hardening, subagent prompt verbatim, Date.now throw, phases cap, test fidelity (P1)
* fix(core): construct Math+Date in vm realm, sever proto chains on injected closures (P1)
* fix(core): sever Array.prototype on args, cap deep-null-proto recursion, consolidate WorkflowAgentResult (P1)
* fix(core): stub parallel/pipeline/workflow/budget globals with P1-unsupported errors
* fix(core): harden budget inner functions, regression-test anti-recursion + args threading
* fix(core): P2/P5 forward-compat injection seams + document error.stack limitation (P1)
* fix(core): vm-realm wrap async sandbox globals + stripExportMeta hardening (PR #4732 R1)
Closes T1/T8/T14: thrown Errors and async-function Promises used to leak the
host Function constructor through their prototype chains. PoC:
agent('x').constructor.constructor('return process')()
try { throw } catch(e) { e.constructor.constructor('return process')() }
Build every async/sync global (agent, parallel, pipeline, workflow, budget,
console, phase, log, args) inside the vm-realm via the existing init
script. Host only exposes a primitive bridge that the init script reads
once and deletes from globalThis. Both rejection and resolution paths
cross the boundary as vm-realm values.
Closes T2: deepNullProto used to setPrototypeOf(null) on array args,
breaking for-of / .map / .filter / spread / destructuring. Replaced with
vm-realm JSON.parse of an args string — arrays retain vm-realm
Array.prototype methods.
Closes T13: runtime allowlist on agent() opts catches typos like 'scema'.
Closes T9/T16/T17: stripExportMeta now recognises //, /* */, and regex
literals; throws on unbalanced braces instead of silently returning ''.
Closes T6: validateArgs rejects functions, BigInt, circular refs, and
over-deep nesting (previously functions silently disappeared).
Closes T5: regression test for console.log/warn/error → getLogs routing.
* fix(core): change WorkflowTool export to type-only (PR #4732 R1 T3)
Production callers use Config.createToolRegistry's registerLazy path which
dynamic-imports './tools/workflow/workflow.js'. The barrel export at index.ts
previously forced eager evaluation of the workflow.js → workflow-orchestrator
→ workflow-sandbox → node:vm module chain for every consumer of
@qwen-code/core, even when workflows are disabled.
Sibling tool exports (AgentTool, SkillTool) are type-only; align WorkflowTool
with the same pattern. SDK consumers can still annotate types; instantiation
happens through the registry, not the barrel.
* fix(core): subagent terminateMode + bounded runConfig + disallowedTools + failure context + defensive serialization (PR #4732 R1)
Closes T10: runReasoningLoop returns terminateMode = CANCELLED|MAX_TURNS|
TIMEOUT|ERROR rather than throwing. Without checking it, await agent(...)
resolved to '' on user cancel and the workflow kept looping. Now check
getTerminateMode() after execute() and throw on non-GOAL — mirrors
AgentTool's existing handling.
Closes T11: workflow subagents previously ran with runConfig: {} (no
max_turns / max_time_minutes guard) and tools: ['*'] without
disallowedTools. A single agent() could loop the model indefinitely. Bound
to 50 turns / 10 minutes; add disallowedTools: [SEND_MESSAGE, EXIT_PLAN_MODE]
to mirror upstream Tg8 — defense in depth with the §XmO system prompt.
Closes T19: phases / logs accumulated before a script failure used to be
discarded with the sandbox instance. WorkflowExecutionError carries them
through the rejection so the user-visible display can show what ran.
Closes T12 / T18: defensive serialization. A successful workflow returning
a BigInt or circular value used to be reported as 'Workflow failed:
Converting circular structure to JSON' because JSON.stringify was inside
the try block. safeStringifyResult / safeStringifyDisplayPayload degrade
to a clear placeholder so a serialization issue doesn't masquerade as a
run failure.
Closes T4: regression test for ToolErrorType.EXECUTION_FAILED assertion.
Closes T7: vi as vitest alias removed (now matches every other test file).
* chore(core): add missing @license headers + remove stale config-session-env reference (PR #4732 R1)
Closes T20: 6 of 9 new workflow files were missing the standard @license
Apache-2.0 header. Add Qwen-style header (matches sibling tools/agent/agent.ts
and others) to: workflow-sandbox.ts, workflow-sandbox.test.ts,
workflow-orchestrator.ts, workflow-orchestrator.test.ts,
workflow-prompts.ts, workflow.test.ts.
Closes T21: config.workflows.test.ts and config.workflow-registration.test.ts
both contained 'mirrors config-session-env.test.ts' in a setup comment, but
that file does not exist in the repo. Drop the dangling reference.
* chore(core): clean up stray rebase conflict marker (PR #4732)
* fix(core): sever sandboxGlobals proto + add async wall-clock timeout (PR #4732 R2)
Closes T22: sandboxGlobals was a plain host-realm Object literal. Its
prototype chain reached host Object → host Function → host process,
bypassing every per-global hardening measure. PoC confirmed leak via
`globalThis.constructor.constructor('return process')()` returning
host process before fix. Fix: Object.setPrototypeOf(null) on both
sandboxGlobals and the bridge object before vm.createContext.
Regression tests cover both globalThis.constructor and implicit-this
escape paths.
Closes T23: vm.runInContext timeout only covers synchronous execution.
Once the async IIFE yields its first await, the watchdog disarms and
`return new Promise(() => {})` hangs forever. Fix: wrap in
Promise.race with a wall-clock timeout (default 30 minutes, configurable
via SandboxOptions.maxWallClockMs or QWEN_CODE_MAX_WORKFLOW_SECONDS env
var). This is a permanent defense-in-depth — not P1-only: P2/P3/P5
all add resource caps measured in agent-calls or tokens, but a
0-token / 0-agent hang requires a wall-clock cap.
Documented limitation: an in-script async microtask loop continues
consuming microtasks after the outer wall-clock rejects (node:vm
provides no way to halt async execution). In production the workflow
surface returns the timeout error and the vm context becomes
unreferenced; the leaked microtask loop is a host-process concern
that requires worker_threads-level isolation (out of P1 scope).
* fix(core): pre-sanitize non-serializable result before display payload (PR #4732 R3)
Sibling drift of the R1 T12/T18 fix. safeStringifyResult already degrades
per-field when the script's `result` is a BigInt / circular value, so
llmContent survives. But the success-path display payload wraps
{runId, phases, logs, result} in a single JSON.stringify — one bad
`result` collapsed the whole display to the generic
"(display payload not JSON-serializable)" string and the user lost the
runId (needed for log correlation), the accumulated phases, AND the
logs. Pre-sanitize `result` only; runId / phases / logs are always
serializable.
Add regression test that scripts a circular `result` with a `phase()`
in front: assertions check runId, the phase, and the non-JSON-serializable
placeholder all appear in returnDisplay, and that the atomic-failure
fallback string does NOT appear.
RED at 10:56:23 → fix → GREEN at 10:56:48. 14/14 workflow.test, 109/109
across the workflow test suite, typecheck silent.
* refactor(core): push display-payload per-field fallback into the helper (PR #4732)
Post-R3 /simplify pass. The R3 fix special-cased `result` at the call
site by pre-probing JSON.stringify and substituting a placeholder. Four
review angles (reuse / simplification / efficiency / altitude) all
converged on the same root cause: per-field degradation belongs in
`safeStringifyDisplayPayload`, not duplicated at every caller.
- Altitude: the bug ("all-or-nothing stringify is too coarse") names a
property of the helper; the fix now lives in the helper. Any new
payload field that becomes non-serializable in a future round
(`metrics: bigint`, etc.) is handled without a fresh call-site patch.
- Reuse: the third try/JSON.stringify probe in this file is gone;
the helper owns the probe.
- Simplification: call site reverts to the pre-R3 clean shape.
- Efficiency: success path is back to one stringify per payload.
Helper behavior:
happy path → 1 stringify (unchanged)
one field fails → walk top-level keys, probe each, replace failing
value with `(non-JSON-serializable value of type X)`,
re-stringify; total 2 stringifies of payload + N
field probes. Fall through to the original generic
fallback if the sanitized re-stringify also fails.
R3 regression test (`execute() preserves runId/phases/logs in
returnDisplay when result is non-JSON-serializable`) passes unchanged
— it tests observable behavior, not the implementation site. 109/109
across the workflow suite, typecheck silent.
* fix(core): honest description + meta-strip anchor + wall-clock cancellation + stray gitignore (PR #4732 R4)
Four fixes from R4 review:
T32 (workflow.ts tool description) — P1 description claimed "sequential
only" but `Promise.all([agent(), agent()])` bypasses the claim because
the vm cannot intercept JS built-ins. Rewrite the description to be
honest: P1 ships sequential primitives only (no parallel/pipeline);
Promise.all spawns concurrent subagents that share Config and may race
on file edits. Matches upstream Claude Code behavior — they also expose
Promise.all without enforcement.
T33 (workflow-sandbox.ts stripExportMeta) — drop the `/m` flag on the
anchor regex. With `/m`, a template literal containing
`\nexport const meta = {\n` triggered a false match, and the brace
walker ripped content out of the string body, silently corrupting the
script. Per design intent ("required first statement of every script")
meta must be file-start; anchoring there closes the corruption surface.
Adds two RED-confirmed regression tests (template literal + leading
code) and a sanity test for leading whitespace.
T35 (packages/core/.gitignore) — removed. The `.qwen/computer-use/`
entry was stray scope pollution committed accidentally in R1's license-
header cleanup (`d118c55f8`) and unrelated to the Workflow P1 surface.
T40 (sandbox.ts + orchestrator.ts + workflow.ts) — completes the R2
wall-clock defense. When the timer fires the sandbox now `abort()`s a
caller-supplied AbortController BEFORE rejecting; the controller's
signal is also threaded into `createProductionDispatch`, so in-flight
subagent.execute() calls see the cancellation and stop burning tokens.
Without this, R2's "30 min wall-clock" still let subagents run for up
to their internal `max_time_minutes: 10` after the user-side timed out.
WorkflowTool.execute now derives `dispatchController`, forwards caller
signal abort to it, passes its signal to dispatch and the controller
itself to `orchestrator.run({abortOnTimeout})`. A `finally` block
aborts the controller on natural completion (cancel any straggler
subagent) and detaches the caller-signal listener to avoid leaks.
Adds two sandbox unit tests (RED-confirmed): controller IS aborted on
timeout, controller is NOT aborted on normal completion.
114/114 workflow suite tests pass, typecheck silent.
* refactor(core): use createChildAbortController for T40 dispatch-signal bridge (PR #4732)
Post-R4 /simplify pass. Four review angles converged on one finding:
the T40 manual AbortController-bridging at the call site re-implements
`createChildAbortController` from `packages/core/src/utils/abortController.ts:61`,
which is already the project-idiomatic helper for this exact pattern
(used at agent-headless.ts:231, agent-interactive.ts:157, agent-core.ts:603 & 952).
The replacement collapses 5 lines of imperative listener wiring at
`workflow.ts:execute()` head + 1 line of finally cleanup into a single
`createChildAbortController(signal)` call, plus inherits the helper's
hardenings:
- WeakRef on the parent (so a long-lived caller signal doesn't pin the
child controller)
- Auto-removal of the parent listener when the child fires (covers
both the wall-clock-fire path and the natural-completion path)
- Default 50-listener cap via `setMaxListeners`
No behavior change at the API boundary — the wall-clock `abortOnTimeout`
contract and the test assertions for T40's two cases (controller IS
aborted on wall-clock; controller is NOT aborted on normal completion)
all still hold. The /simplify "altitude" finding (push the bridging
into the orchestrator) is deferred — that would change WorkflowOrchestrator's
constructor/run signature and is outside this PR's scope.
Also trims a redundant inline comment at workflow-orchestrator.ts:172
(the `abortOnTimeout: req.abortOnTimeout` line; the field's type
comment at line 71-81 already explains the contract).
114/114 workflow suite tests pass, typecheck silent.
* chore(core): compress over-weight T40 comments after createChildAbortController refactor (PR #4732)
The previous commit moved the bridging logic into the helper, so the
inline comments restating the helper's contract (parent forwarding,
already-aborted fast path, WeakRef, auto-removal) became redundant —
that's the helper's job to document.
Compress to the load-bearing semantics: the child controller sees
both caller-driven and wall-clock-driven aborts, and `finally` cancels
stragglers on normal completion. No code change.
* chore(core): align copyright header to Qwen on 2 PR-new test files (PR #4732 R7 F4)
Both files were derived from a template (the stale `config-session-env.test.ts`
reference cleaned up in R1 T21) and retained the upstream `Copyright 2025
Google LLC` header. The other six new workflow source/test files in this
PR carry `Copyright 2025 Qwen`. Align for same-PR consistency.
Per DragonnZhang R7 F4. No behavior change; header text only.
---------
Co-authored-by: tanzhenxin <tanzhenxing1987@gmail.com>
This commit is contained in:
parent
cc141ffc32
commit
7757d87850
13 changed files with 3112 additions and 0 deletions
|
|
@ -100,6 +100,10 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
|
|||
// never enter or exit the user's worktree state independently.
|
||||
ToolNames.ENTER_WORKTREE,
|
||||
ToolNames.EXIT_WORKTREE,
|
||||
// FIX-8 (SEC-I1): WORKFLOW is excluded to prevent unbounded recursive
|
||||
// fan-out: a subagent spawned by Workflow that calls Workflow would create
|
||||
// O(k^n) subagents.
|
||||
ToolNames.WORKFLOW,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
294
packages/core/src/agents/runtime/workflow-orchestrator.test.ts
Normal file
294
packages/core/src/agents/runtime/workflow-orchestrator.test.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// T7 (PR #4732 R1): the `vi as vitest` alias diverges from every other
|
||||
// test file in the repo. Use `vi` directly.
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
WorkflowOrchestrator,
|
||||
WorkflowExecutionError,
|
||||
createProductionDispatch,
|
||||
} from './workflow-orchestrator.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
|
||||
// FIX-C3 (TST-2-C1): use vi.hoisted so `created` is initialised before the
|
||||
// vi.mock factory runs AND remains accessible inside tests for assertion +
|
||||
// reset between cases. Without this, the module-level `created` array
|
||||
// accumulated across tests, so a later test could pass by coincidence.
|
||||
//
|
||||
// FIX-C8 (TST-2-I2): record the full 9-arg signature of AgentHeadless.create
|
||||
// and the (ctx, signal?) shape of execute so any drift between the production
|
||||
// call site and the real AgentHeadless surface becomes a test failure.
|
||||
const { created, nextTerminateMode } = vi.hoisted(() => ({
|
||||
created: [] as Array<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
signal?: AbortSignal;
|
||||
promptConfigSystemPrompt?: string;
|
||||
runConfig?: { max_turns?: number; max_time_minutes?: number };
|
||||
toolConfig?: { tools?: string[]; disallowedTools?: string[] };
|
||||
}>,
|
||||
// T10 (PR #4732 R1): the production dispatch checks getTerminateMode() and
|
||||
// throws on non-GOAL. Tests set `nextTerminateMode.value` to simulate
|
||||
// CANCELLED / MAX_TURNS / TIMEOUT outcomes.
|
||||
nextTerminateMode: { value: 'GOAL' as string },
|
||||
}));
|
||||
|
||||
vi.mock('./agent-headless.js', () => ({
|
||||
AgentHeadless: {
|
||||
create: async (
|
||||
name: string,
|
||||
_runtimeContext: unknown,
|
||||
promptConfig: { systemPrompt?: string },
|
||||
_modelConfig: unknown,
|
||||
runConfig: { max_turns?: number; max_time_minutes?: number },
|
||||
toolConfig?: { tools?: string[]; disallowedTools?: string[] },
|
||||
// The next three optional params reflect the real AgentHeadless.create
|
||||
// signature (eventEmitter?, hooks?, runtimeView?). Accepting them as
|
||||
// `unknown` lets the mock detect if the production call site ever adds
|
||||
// a positional argument that the mock would silently drop.
|
||||
_eventEmitter?: unknown,
|
||||
_hooks?: unknown,
|
||||
_runtimeView?: unknown,
|
||||
) => ({
|
||||
execute: async (
|
||||
ctx: { get: (k: string) => unknown },
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
created.push({
|
||||
name,
|
||||
prompt: ctx.get('task_prompt') as string,
|
||||
signal,
|
||||
promptConfigSystemPrompt: promptConfig.systemPrompt,
|
||||
runConfig,
|
||||
toolConfig,
|
||||
});
|
||||
if (
|
||||
!promptConfig.systemPrompt?.includes('subagent spawned by a workflow')
|
||||
) {
|
||||
throw new Error(
|
||||
'orchestrator did not pass workflow subagent system prompt',
|
||||
);
|
||||
}
|
||||
},
|
||||
getFinalText: () =>
|
||||
`headless-said:${created[created.length - 1]!.prompt}`,
|
||||
getTerminateMode: () => nextTerminateMode.value,
|
||||
}),
|
||||
},
|
||||
ContextState: class ContextState {
|
||||
private state: Record<string, unknown> = {};
|
||||
get(key: string): unknown {
|
||||
return this.state[key];
|
||||
}
|
||||
set(key: string, value: unknown): void {
|
||||
this.state[key] = value;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
function fakeConfig(): Config {
|
||||
// createProductionDispatch uses Config only when constructing a real subagent.
|
||||
// In tests we either inject a mock dispatch or test createProductionDispatch
|
||||
// directly against the vi.mock above. An empty object cast is safe.
|
||||
return {} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('WorkflowOrchestrator', () => {
|
||||
it('runs a script with injected mock dispatch and returns the script value', async () => {
|
||||
const orchestrator = new WorkflowOrchestrator(
|
||||
async (prompt) => `mock:${prompt}`,
|
||||
);
|
||||
const outcome = await orchestrator.run({
|
||||
script: `phase("plan");
|
||||
const x = await agent("hi", { label: "a" });
|
||||
return x;`,
|
||||
args: undefined,
|
||||
});
|
||||
expect(outcome.result).toBe('mock:hi');
|
||||
expect(outcome.runId).toMatch(/^wf_[0-9a-f]{16}$/);
|
||||
expect(outcome.phases).toEqual(['plan']);
|
||||
});
|
||||
|
||||
it('passes args through to the script', async () => {
|
||||
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
|
||||
const outcome = await orchestrator.run({
|
||||
script: `return args.who`,
|
||||
args: { who: 'world' },
|
||||
});
|
||||
expect(outcome.result).toBe('world');
|
||||
});
|
||||
|
||||
it('surfaces a thrown error from the script', async () => {
|
||||
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
|
||||
await expect(
|
||||
orchestrator.run({
|
||||
script: `throw new Error("boom")`,
|
||||
args: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/boom/);
|
||||
});
|
||||
|
||||
it('runId is stable for the lifetime of a single run call', async () => {
|
||||
const captured: string[] = [];
|
||||
const orchestrator = new WorkflowOrchestrator(async (prompt) => {
|
||||
captured.push(prompt);
|
||||
return 'ok';
|
||||
});
|
||||
const outcome = await orchestrator.run({
|
||||
script: `await agent("first"); await agent("second"); return 0;`,
|
||||
args: undefined,
|
||||
});
|
||||
expect(captured).toEqual(['first', 'second']);
|
||||
expect(outcome.runId).toMatch(/^wf_[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
// TST-C1: concurrent runs must produce distinct runIds.
|
||||
it('runId is unique across concurrent runs', async () => {
|
||||
const orchestrator = new WorkflowOrchestrator(async () => 'ok');
|
||||
const [a, b, c] = await Promise.all([
|
||||
orchestrator.run({ script: 'return 1', args: undefined }),
|
||||
orchestrator.run({ script: 'return 2', args: undefined }),
|
||||
orchestrator.run({ script: 'return 3', args: undefined }),
|
||||
]);
|
||||
expect(a.runId).not.toBe(b.runId);
|
||||
expect(b.runId).not.toBe(c.runId);
|
||||
expect(a.runId).not.toBe(c.runId);
|
||||
});
|
||||
|
||||
// TST-C2: a dispatch rejection must propagate out through the sandbox.
|
||||
it('propagates dispatch rejection through the script', async () => {
|
||||
const orchestrator = new WorkflowOrchestrator(async () => {
|
||||
throw new Error('agent-crashed');
|
||||
});
|
||||
await expect(
|
||||
orchestrator.run({
|
||||
script: 'await agent("x"); return 0;',
|
||||
args: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/agent-crashed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProductionDispatch', () => {
|
||||
// FIX-C3: reset the shared mock-state array between tests so each case
|
||||
// observes its own subagent.execute call only. Also reset the simulated
|
||||
// terminate mode back to 'goal' (success).
|
||||
beforeEach(() => {
|
||||
created.length = 0;
|
||||
nextTerminateMode.value = 'GOAL';
|
||||
});
|
||||
|
||||
it('routes calls through AgentHeadless and returns getFinalText', async () => {
|
||||
const dispatch = createProductionDispatch(fakeConfig());
|
||||
const result = await dispatch('hello', { label: 'h1' });
|
||||
expect(result).toBe('headless-said:hello');
|
||||
expect(created.length).toBe(1);
|
||||
expect(created[0]!.name).toBe('h1');
|
||||
expect(created[0]!.prompt).toBe('hello');
|
||||
});
|
||||
|
||||
// FIX-C4 (TST-2-C2): the previous test only asserted no-crash. This one
|
||||
// actually captures the signal in the mock and asserts identity, so a
|
||||
// regression that drops the second arg of subagent.execute() would fail.
|
||||
it('threads abort signal through to subagent.execute', async () => {
|
||||
const controller = new AbortController();
|
||||
const dispatch = createProductionDispatch(fakeConfig(), controller.signal);
|
||||
await dispatch('hello', { label: 'h1' });
|
||||
expect(created.length).toBe(1);
|
||||
expect(created[0]!.signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it('passes undefined signal when none provided', async () => {
|
||||
const dispatch = createProductionDispatch(fakeConfig());
|
||||
await dispatch('hello', { label: 'h1' });
|
||||
expect(created.length).toBe(1);
|
||||
expect(created[0]!.signal).toBeUndefined();
|
||||
});
|
||||
|
||||
// FIX-C2 (UP-2-C1): the subagent system prompt must include the binary's
|
||||
// §XmO bullets. We assert the JSON-format instruction is present because
|
||||
// its absence causes JSON-returning subagents to wrap output in code fences.
|
||||
it('passes the binary §XmO verbatim system prompt to subagent', async () => {
|
||||
const dispatch = createProductionDispatch(fakeConfig());
|
||||
await dispatch('hello', { label: 'h1' });
|
||||
const sp = created[0]!.promptConfigSystemPrompt ?? '';
|
||||
expect(sp).toContain('subagent spawned by a workflow');
|
||||
expect(sp).toContain('return ONLY the raw JSON');
|
||||
expect(sp).toContain('no code fences');
|
||||
expect(sp).toContain('SendUserMessage');
|
||||
});
|
||||
|
||||
// T11 (PR #4732 R1): subagents must be bounded so a single agent() call
|
||||
// cannot loop the model indefinitely.
|
||||
it('passes bounded runConfig (max_turns + max_time_minutes)', async () => {
|
||||
const dispatch = createProductionDispatch(fakeConfig());
|
||||
await dispatch('hello', { label: 'h1' });
|
||||
expect(created[0]!.runConfig).toEqual({
|
||||
max_turns: 50,
|
||||
max_time_minutes: 10,
|
||||
});
|
||||
});
|
||||
|
||||
// T11: disallow SendMessage / ExitPlanMode to mirror upstream Tg8.
|
||||
it('disallows SendMessage and ExitPlanMode for workflow subagents', async () => {
|
||||
const dispatch = createProductionDispatch(fakeConfig());
|
||||
await dispatch('hello', { label: 'h1' });
|
||||
expect(created[0]!.toolConfig?.tools).toEqual(['*']);
|
||||
expect(created[0]!.toolConfig?.disallowedTools).toEqual([
|
||||
'send_message',
|
||||
'exit_plan_mode',
|
||||
]);
|
||||
});
|
||||
|
||||
// T10 (PR #4732 R1): the production dispatch must throw when the
|
||||
// subagent terminates with a non-GOAL mode. Without this, `await agent(...)`
|
||||
// would resolve to '' on user cancel and the script would keep running.
|
||||
it.each([
|
||||
['CANCELLED', /terminate mode: CANCELLED/],
|
||||
['MAX_TURNS', /terminate mode: MAX_TURNS/],
|
||||
['TIMEOUT', /terminate mode: TIMEOUT/],
|
||||
['ERROR', /terminate mode: ERROR/],
|
||||
])(
|
||||
'throws when subagent terminate mode is %s',
|
||||
async (mode, expectedMessage) => {
|
||||
nextTerminateMode.value = mode;
|
||||
const dispatch = createProductionDispatch(fakeConfig());
|
||||
await expect(dispatch('hello', { label: 'h1' })).rejects.toThrow(
|
||||
expectedMessage,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('WorkflowOrchestrator failure-context preservation', () => {
|
||||
// T19 (PR #4732 R1): phases / logs accumulated before a script failure
|
||||
// must be preserved on the thrown error so the tool layer can display
|
||||
// them. Previously the sandbox instance was discarded with the error.
|
||||
it('throws WorkflowExecutionError carrying phases and logs on script failure', async () => {
|
||||
const orchestrator = new WorkflowOrchestrator(async () => 'ok');
|
||||
let caught: unknown;
|
||||
try {
|
||||
await orchestrator.run({
|
||||
script: `
|
||||
phase("plan");
|
||||
log("starting");
|
||||
phase("execute");
|
||||
log("about to fail");
|
||||
throw new Error("scripted failure");
|
||||
`,
|
||||
args: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(WorkflowExecutionError);
|
||||
const wfErr = caught as WorkflowExecutionError;
|
||||
expect(wfErr.message).toContain('scripted failure');
|
||||
expect(wfErr.phases).toEqual(['plan', 'execute']);
|
||||
expect(wfErr.logs).toEqual(['starting', 'about to fail']);
|
||||
});
|
||||
});
|
||||
214
packages/core/src/agents/runtime/workflow-orchestrator.ts
Normal file
214
packages/core/src/agents/runtime/workflow-orchestrator.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { createWorkflowSandbox } from './workflow-sandbox.js';
|
||||
import type {
|
||||
WorkflowAgentOpts,
|
||||
WorkflowAgentResult,
|
||||
} from './workflow-sandbox.js';
|
||||
import { WORKFLOW_SUBAGENT_SYSTEM_PROMPT } from './workflow-prompts.js';
|
||||
import { AgentTerminateMode } from './agent-types.js';
|
||||
import { ToolNames } from '../../tools/tool-names.js';
|
||||
|
||||
/**
|
||||
* Bound the resource ceiling for workflow subagents so a single `agent()`
|
||||
* call cannot loop the model indefinitely. Values mirror conservative
|
||||
* upstream defaults; P5 will refine via `budget` once it exists.
|
||||
*/
|
||||
const WORKFLOW_SUBAGENT_MAX_TURNS = 50;
|
||||
const WORKFLOW_SUBAGENT_MAX_TIME_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* disallowedTools mirror the upstream `Tg8` workflow-subagent config — both
|
||||
* tools would let a subagent break the "final text IS the return value"
|
||||
* contract. SendMessage would deliver the answer to the user instead of
|
||||
* the calling script; ExitPlanMode would interrupt the workflow's plan-mode
|
||||
* intent. Defense-in-depth alongside the §XmO system prompt that already
|
||||
* documents both restrictions.
|
||||
*/
|
||||
const WORKFLOW_SUBAGENT_DISALLOWED_TOOLS: string[] = [
|
||||
ToolNames.SEND_MESSAGE,
|
||||
ToolNames.EXIT_PLAN_MODE,
|
||||
];
|
||||
|
||||
/**
|
||||
* `WorkflowExecutionError` preserves the phases and logs the script
|
||||
* accumulated before failing — without it, all diagnostic context is lost
|
||||
* when the orchestrator's catch block surfaces only `err.message`.
|
||||
*
|
||||
* `cause` carries the underlying error message but no host-realm Error
|
||||
* object: we only ever store strings to avoid re-introducing the T1
|
||||
* thrown-Error realm-escape vector.
|
||||
*/
|
||||
export class WorkflowExecutionError extends Error {
|
||||
override readonly name = 'WorkflowExecutionError';
|
||||
constructor(
|
||||
message: string,
|
||||
readonly phases: string[],
|
||||
readonly logs: string[],
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// FIX-E (Round 4 ARCH-I1): single source of truth for the dispatch return
|
||||
// type is `workflow-sandbox.ts`. Re-exported here so external consumers
|
||||
// (WorkflowTool) can import the alias from the orchestrator module.
|
||||
export type { WorkflowAgentResult };
|
||||
|
||||
export interface WorkflowRunRequest {
|
||||
script: string;
|
||||
args: unknown;
|
||||
// FIX-D (Round 3 ARCH-I1): `signal` was previously declared here but never
|
||||
// read by `run()` — cancellation flows through `createProductionDispatch`'s
|
||||
// closure-captured signal, not via per-run state. Removed to prevent
|
||||
// P2 authors from extending the wrong field.
|
||||
/**
|
||||
* T40 (PR #4732 R4): caller-owned AbortController linked to the wall-clock
|
||||
* timeout. When the sandbox times out, this controller is aborted BEFORE
|
||||
* the rejection propagates — letting in-flight subagent dispatches see
|
||||
* the cancellation and stop burning tokens. The caller (`WorkflowTool`)
|
||||
* also threads this same controller's signal into `createProductionDispatch`
|
||||
* and aborts it in its own `finally` block to clean up on normal completion.
|
||||
* If omitted, the wall-clock still rejects but in-flight subagents continue
|
||||
* until their internal `max_time_minutes` limit.
|
||||
*/
|
||||
abortOnTimeout?: AbortController;
|
||||
}
|
||||
|
||||
export interface WorkflowRunOutcome {
|
||||
runId: string;
|
||||
result: unknown;
|
||||
phases: string[];
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export type WorkflowAgentDispatch = (
|
||||
prompt: string,
|
||||
opts: WorkflowAgentOpts,
|
||||
) => Promise<WorkflowAgentResult>;
|
||||
|
||||
function generateRunId(): string {
|
||||
return `wf_${randomBytes(8).toString('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the production agent-dispatch function.
|
||||
*
|
||||
* Wraps AgentHeadless.create + execute + getFinalText into the
|
||||
* `(prompt, opts) => Promise<string>` shape required by the sandbox.
|
||||
*
|
||||
* Dynamic import lets test mocks swap agent-headless without static-import
|
||||
* hoisting interference.
|
||||
*
|
||||
* FIX-6 (ARCH-C1): accepts an optional AbortSignal and threads it into
|
||||
* subagent.execute() so cancellation from the caller propagates correctly.
|
||||
* When signal is undefined, subagent.execute() runs without external abort.
|
||||
*/
|
||||
export function createProductionDispatch(
|
||||
config: Config,
|
||||
signal?: AbortSignal,
|
||||
): WorkflowAgentDispatch {
|
||||
return async (prompt, opts) => {
|
||||
const { AgentHeadless, ContextState } = await import('./agent-headless.js');
|
||||
const ctx = new ContextState();
|
||||
ctx.set('task_prompt', prompt);
|
||||
|
||||
const subagent = await AgentHeadless.create(
|
||||
opts.label ?? 'workflow-agent',
|
||||
config,
|
||||
{
|
||||
systemPrompt: WORKFLOW_SUBAGENT_SYSTEM_PROMPT,
|
||||
initialMessages: [],
|
||||
},
|
||||
{},
|
||||
// T11 (PR #4732 R1): bound resource ceiling so a single agent() call
|
||||
// cannot loop the model indefinitely. Without this, runConfig was {}
|
||||
// and the loop guards never tripped — combined with the cancellation
|
||||
// bug below, workflows were effectively unkillable.
|
||||
{
|
||||
max_turns: WORKFLOW_SUBAGENT_MAX_TURNS,
|
||||
max_time_minutes: WORKFLOW_SUBAGENT_MAX_TIME_MINUTES,
|
||||
},
|
||||
// T11 (PR #4732 R1): disallow SendMessage / ExitPlanMode to align with
|
||||
// upstream Tg8 — closes the back-channel that would let a subagent
|
||||
// deliver its answer via user message instead of the script's read.
|
||||
{ tools: ['*'], disallowedTools: WORKFLOW_SUBAGENT_DISALLOWED_TOOLS },
|
||||
);
|
||||
await subagent.execute(ctx, signal);
|
||||
// T10 (PR #4732 R1): runReasoningLoop does NOT throw on abort / turn /
|
||||
// time limit — it returns with terminateMode = CANCELLED|MAX_TURNS|
|
||||
// TIMEOUT|ERROR and getFinalText() = '' or partial. Without this check,
|
||||
// `await agent(...)` would resolve to '' on user cancel and the script
|
||||
// would happily loop on empty results.
|
||||
const mode = subagent.getTerminateMode();
|
||||
if (mode !== AgentTerminateMode.GOAL) {
|
||||
throw new Error(
|
||||
`Workflow subagent did not complete (terminate mode: ${mode}).`,
|
||||
);
|
||||
}
|
||||
return subagent.getFinalText();
|
||||
};
|
||||
}
|
||||
|
||||
export class WorkflowOrchestrator {
|
||||
constructor(private readonly dispatch: WorkflowAgentDispatch) {}
|
||||
|
||||
async run(req: WorkflowRunRequest): Promise<WorkflowRunOutcome> {
|
||||
// Signal threading lives in createProductionDispatch (closure-captured)
|
||||
// rather than per-run state. Sandbox-level signal is intentionally not
|
||||
// exposed in P1 — sync-loop protection is provided by the 30s vm
|
||||
// timeout in workflow-sandbox.ts; async-loop cancellation flows
|
||||
// through dispatch's subagent.execute path.
|
||||
const runId = generateRunId();
|
||||
const sandbox = createWorkflowSandbox({
|
||||
args: req.args,
|
||||
dispatch: this.dispatch,
|
||||
abortOnTimeout: req.abortOnTimeout,
|
||||
});
|
||||
try {
|
||||
const result = await sandbox.run(req.script);
|
||||
return {
|
||||
runId,
|
||||
result,
|
||||
phases: sandbox.getPhases(),
|
||||
logs: sandbox.getLogs(),
|
||||
};
|
||||
} catch (err) {
|
||||
// T19 (PR #4732 R1): preserve phases and logs accumulated before the
|
||||
// script failed so the caller can surface them in the error display.
|
||||
// We only carry primitive strings across the boundary — no host-realm
|
||||
// Error instance — to avoid reintroducing the T1 escape vector.
|
||||
//
|
||||
// Cross-realm `instanceof Error` is false for vm-realm Error objects,
|
||||
// so duck-type on `.message` instead. `String(vmError)` would coerce
|
||||
// to "Error: <msg>" which is the wrong shape for a clean message.
|
||||
throw new WorkflowExecutionError(
|
||||
extractErrorMessage(err),
|
||||
sandbox.getPhases(),
|
||||
sandbox.getLogs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duck-typed message extraction. `instanceof Error` is realm-local; vm-realm
|
||||
* Errors raised inside the sandbox are NOT instances of host Error from the
|
||||
* orchestrator's perspective, so the standard `err instanceof Error ?
|
||||
* err.message : String(err)` pattern produces "Error: msg" via toString().
|
||||
* This helper falls back to the .message property regardless of realm.
|
||||
*/
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const m = (err as { message: unknown }).message;
|
||||
if (typeof m === 'string') return m;
|
||||
return String(m);
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
37
packages/core/src/agents/runtime/workflow-prompts.ts
Normal file
37
packages/core/src/agents/runtime/workflow-prompts.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* System prompts for workflow subagents.
|
||||
*
|
||||
* Verbatim from claude-code 2.1.160 binary's §XmO constant (confirmed via
|
||||
* `strings -a -n 6 <binary> | rg "You are a subagent spawned by a workflow"`).
|
||||
* Kept in its own module so future phases (P3 schema mode via §ZmO, P5 budget
|
||||
* guidance) can introduce variant prompts without touching the orchestrator.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base subagent prompt — used when no schema is set on agent() opts.
|
||||
*
|
||||
* VERBATIM from claude-code 2.1.160 binary §XmO. The five bullet points are
|
||||
* load-bearing for subagent behavior alignment:
|
||||
* - "Output the literal result" — discourages explanatory text
|
||||
* - "raw JSON ... no code fences" — critical for schema-returning agents in P3
|
||||
* - "Do NOT use SendUserMessage" — closes the back-channel escape hatch
|
||||
* - "Be concise" — bounds token cost
|
||||
*
|
||||
* P1 omits the §ZmO variant (schema-mode) because P1 throws on agent({schema}).
|
||||
* When P3 adds StructuredOutput, add WORKFLOW_SUBAGENT_SYSTEM_PROMPT_WITH_SCHEMA
|
||||
* here as a separate const.
|
||||
*/
|
||||
export const WORKFLOW_SUBAGENT_SYSTEM_PROMPT =
|
||||
'You are a subagent spawned by a workflow orchestration script. ' +
|
||||
'Use the tools available to complete the task.\n' +
|
||||
'CRITICAL: Your final text response is returned **verbatim** as a string to the calling script — it is your return value, not a message to a human.\n' +
|
||||
'- Output the literal result (data, JSON, text). Do NOT output confirmations like "Done." or "Sent."\n' +
|
||||
'- If asked for JSON, return ONLY the raw JSON — no code fences, no prose, no markdown.\n' +
|
||||
'- Do NOT use SendUserMessage to deliver your answer. Put your answer in your final text response.\n' +
|
||||
'- Be concise. The script will parse your output.';
|
||||
1073
packages/core/src/agents/runtime/workflow-sandbox.test.ts
Normal file
1073
packages/core/src/agents/runtime/workflow-sandbox.test.ts
Normal file
File diff suppressed because it is too large
Load diff
643
packages/core/src/agents/runtime/workflow-sandbox.ts
Normal file
643
packages/core/src/agents/runtime/workflow-sandbox.ts
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip a leading `export const meta = { ... }` declaration from a workflow
|
||||
* script. Required because Node's vm script mode rejects ES module syntax.
|
||||
*
|
||||
* P1 does not use meta semantically; it is removed so that Claude-Code-trained
|
||||
* models whose first line is `export const meta = {...}` do not produce a
|
||||
* SyntaxError at sandbox parse time.
|
||||
*
|
||||
* Recognises `//` / `/* *\/` comments and regex literals in addition to
|
||||
* string literals (single, double, template). Throws on unbalanced braces
|
||||
* instead of returning a truncated string — silently deleting the script
|
||||
* body produced the worst-case failure mode (workflow runs, returns
|
||||
* undefined, no diagnostic).
|
||||
*
|
||||
* Template-literal `${...}` substitutions that contain `{` or `}` are not
|
||||
* supported — model-authored `meta` should avoid them.
|
||||
*/
|
||||
export function stripExportMeta(source: string): string {
|
||||
// T33 (PR #4732 R4): anchor at file start (no `/m` flag). Per the design
|
||||
// doc, `export const meta = {...}` must be the script's FIRST statement.
|
||||
// With `/m`, the regex matched every line-start occurrence — including
|
||||
// inside template literals — and the brace-walker then ripped content
|
||||
// out of the string body, silently corrupting the script.
|
||||
const re = /^\s*export\s+const\s+meta\s*=\s*\{/;
|
||||
const match = re.exec(source);
|
||||
if (!match) return source;
|
||||
const exportIdx = match.index;
|
||||
const startBrace = source.indexOf('{', exportIdx);
|
||||
let depth = 1;
|
||||
let i = startBrace + 1;
|
||||
while (i < source.length && depth > 0) {
|
||||
const ch = source[i];
|
||||
const next = source[i + 1];
|
||||
// Single-line comment: skip to newline (T16).
|
||||
if (ch === '/' && next === '/') {
|
||||
i += 2;
|
||||
while (i < source.length && source[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
// Block comment: skip to closing `*/` (T16).
|
||||
if (ch === '/' && next === '*') {
|
||||
i += 2;
|
||||
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/'))
|
||||
i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Regex literal: skip to matching `/`. We accept the heuristic that a `/`
|
||||
// appearing as a value in `{` context is a regex literal, not division
|
||||
// — meta objects don't perform arithmetic on properties.
|
||||
if (ch === '/' && isRegexContext(source, i)) {
|
||||
i++;
|
||||
let inClass = false;
|
||||
while (
|
||||
i < source.length &&
|
||||
(inClass || source[i] !== '/') &&
|
||||
source[i] !== '\n'
|
||||
) {
|
||||
if (source[i] === '\\') i += 2;
|
||||
else if (source[i] === '[') {
|
||||
inClass = true;
|
||||
i++;
|
||||
} else if (source[i] === ']') {
|
||||
inClass = false;
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
i++; // skip closing /
|
||||
// Skip flags
|
||||
while (i < source.length && /[gimsuy]/.test(source[i]!)) i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
const q = ch;
|
||||
i++;
|
||||
while (i < source.length && source[i] !== q) {
|
||||
if (source[i] === '\\') i++; // skip escaped char
|
||||
i++;
|
||||
}
|
||||
i++; // skip closing quote
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
// T9/T17: refuse to truncate the script when the meta block is unbalanced.
|
||||
// Returning `""` previously caused the entire workflow body to vanish
|
||||
// silently — the worst possible failure mode.
|
||||
if (depth !== 0) {
|
||||
throw new Error(
|
||||
'stripExportMeta: unbalanced braces in export const meta declaration — ' +
|
||||
'the workflow script cannot be safely stripped. Check the meta block syntax.',
|
||||
);
|
||||
}
|
||||
// Skip trailing whitespace and an optional semicolon.
|
||||
while (i < source.length && /[\s;]/.test(source[i]!)) i++;
|
||||
return source.slice(0, exportIdx) + source.slice(i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: a `/` at offset `i` is a regex literal (not division) if the
|
||||
* previous non-whitespace character is an operator, opening brace/bracket,
|
||||
* comma, colon, or `=` — i.e. positions where a value is expected.
|
||||
*/
|
||||
function isRegexContext(source: string, i: number): boolean {
|
||||
let j = i - 1;
|
||||
while (j >= 0 && /\s/.test(source[j]!)) j--;
|
||||
if (j < 0) return true;
|
||||
const prev = source[j]!;
|
||||
return /[{[(,;:=!&|?+\-*/%^~<>]/.test(prev);
|
||||
}
|
||||
|
||||
import * as vm from 'node:vm';
|
||||
|
||||
// Cap log + phase lines to prevent unbounded memory growth from runaway
|
||||
// model-authored loops.
|
||||
const MAX_LOG_LINES = 10_000;
|
||||
const MAX_PHASE_ENTRIES = 10_000;
|
||||
// Max nesting depth for args; defends against stack-overflow on deeply
|
||||
// nested model-authored input.
|
||||
const ARGS_MAX_DEPTH = 64;
|
||||
|
||||
/**
|
||||
* WorkflowAgentOpts — structured options for the `agent()` global.
|
||||
*
|
||||
* The named fields below are explicitly recognised. P1 throws for unsupported
|
||||
* fields (`schema`, `model`, `isolation`, `agentType`) rather than silently
|
||||
* dropping them. The runtime allowlist enforced in the vm-realm init script
|
||||
* additionally throws on ANY field not in the known set — catching typos
|
||||
* like `scema` before they reach dispatch.
|
||||
*/
|
||||
export interface WorkflowAgentOpts {
|
||||
label?: string;
|
||||
phase?: string;
|
||||
schema?: object;
|
||||
model?: string;
|
||||
isolation?: 'worktree' | 'remote';
|
||||
agentType?: string;
|
||||
// The index signature exists so TypeScript accepts forward-compat opt names
|
||||
// at compile time; the runtime allowlist still rejects unknown names.
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward-compatibility alias for the agent dispatch return type. P1: always
|
||||
* `string`. P3 will widen this to support StructuredOutput.
|
||||
*/
|
||||
export type WorkflowAgentResult = string;
|
||||
|
||||
/**
|
||||
* P5: budget global API surface. P1 default is throwing stubs (total = null,
|
||||
* spent()/remaining() throw). P5 will inject a real tracker.
|
||||
*/
|
||||
export interface WorkflowBudget {
|
||||
total: number | null;
|
||||
spent(): number;
|
||||
remaining(): number;
|
||||
}
|
||||
|
||||
export interface SandboxOptions {
|
||||
/** Value bound to the `args` global inside the script. */
|
||||
args: unknown;
|
||||
/**
|
||||
* Function called by the script's `agent(prompt, opts)` global. Returns the
|
||||
* agent's final text. Injected so tests can mock without spawning an LLM.
|
||||
*/
|
||||
dispatch: (
|
||||
prompt: string,
|
||||
opts: WorkflowAgentOpts,
|
||||
) => Promise<WorkflowAgentResult>;
|
||||
/**
|
||||
* Forward-compatibility injection seams for P2 (parallel / pipeline) and
|
||||
* P5 (budget). When omitted the sandbox falls back to throwing stubs.
|
||||
*/
|
||||
parallel?: (thunks: Array<() => Promise<unknown>>) => Promise<unknown[]>;
|
||||
pipeline?: (
|
||||
items: unknown[],
|
||||
...stages: Array<
|
||||
(prev: unknown, item: unknown, idx: number) => Promise<unknown>
|
||||
>
|
||||
) => Promise<unknown[]>;
|
||||
budget?: WorkflowBudget;
|
||||
/**
|
||||
* T23 (PR #4732 R2): async wall-clock cap (ms) covering the entire script
|
||||
* including awaits. The vm `timeout` option only covers the synchronous
|
||||
* portion; once the IIFE yields its first `await`, the watchdog is
|
||||
* disarmed and `return new Promise(() => {})` would hang forever.
|
||||
*
|
||||
* Defaults to 30 minutes, override via `QWEN_CODE_MAX_WORKFLOW_SECONDS`
|
||||
* env var, or pass an explicit value here (tests use small values for
|
||||
* fast verification).
|
||||
*
|
||||
* This stays a permanent defense even after P5's `budget` ships:
|
||||
* budget caps tokens, but a 0-token hang (`new Promise(() => {})`) only
|
||||
* a wall-clock can catch.
|
||||
*/
|
||||
maxWallClockMs?: number;
|
||||
/**
|
||||
* T40 (PR #4732 R4): completes the R2 wall-clock defense. When the timer
|
||||
* fires, the sandbox `abort()`s this controller BEFORE rejecting. The
|
||||
* caller threads the same controller's `signal` into the dispatch
|
||||
* function (via `createProductionDispatch`) so in-flight subagents see
|
||||
* the abort and stop. Without this, the workflow user-side rejects but
|
||||
* the subagent keeps burning tokens until its own `max_time_minutes`
|
||||
* limit (10 min default).
|
||||
*
|
||||
* The caller is responsible for cleanup on natural completion (call
|
||||
* `abort()` in a `finally` block to cancel any straggler dispatch).
|
||||
*/
|
||||
abortOnTimeout?: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* T23 (PR #4732 R2): default async wall-clock cap. 30 minutes is generous
|
||||
* for any realistic P1 sequential workflow (single agent capped at
|
||||
* 10 min × ~10 agents max practical → ~100 min upper bound, but typical
|
||||
* workflows finish in seconds). 30 min stops 0-token hang patterns
|
||||
* before they waste operator hours.
|
||||
*/
|
||||
const DEFAULT_MAX_WALL_CLOCK_MS = 30 * 60 * 1000;
|
||||
|
||||
function resolveMaxWallClockMs(opts: SandboxOptions): number {
|
||||
if (typeof opts.maxWallClockMs === 'number' && opts.maxWallClockMs > 0) {
|
||||
return opts.maxWallClockMs;
|
||||
}
|
||||
const envSec = Number(process.env['QWEN_CODE_MAX_WORKFLOW_SECONDS']);
|
||||
if (Number.isFinite(envSec) && envSec > 0) return envSec * 1000;
|
||||
return DEFAULT_MAX_WALL_CLOCK_MS;
|
||||
}
|
||||
|
||||
export interface WorkflowSandbox {
|
||||
/**
|
||||
* Execute the user-authored script source. The script is wrapped as an async
|
||||
* IIFE so it may use top-level `await` and `return`. Returns the script's
|
||||
* top-level return value.
|
||||
*/
|
||||
run(scriptSource: string): Promise<unknown>;
|
||||
/** Phase titles announced by the script in order. */
|
||||
getPhases(): string[];
|
||||
/** Log lines emitted by the script in order. */
|
||||
getLogs(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate `args` without mutating it. Throws on functions, BigInts, circular
|
||||
* references, and nesting beyond `ARGS_MAX_DEPTH`. The actual sandbox `args`
|
||||
* global is built INSIDE the vm context via `JSON.parse` so it inherits
|
||||
* vm-realm prototypes — this validation just gates what we hand to JSON
|
||||
* stringification.
|
||||
*/
|
||||
function validateArgs(
|
||||
val: unknown,
|
||||
depth = 0,
|
||||
seen: WeakSet<object> = new WeakSet(),
|
||||
): void {
|
||||
if (depth > ARGS_MAX_DEPTH) {
|
||||
throw new Error(
|
||||
`WorkflowSandbox: args exceeded max nesting depth of ${ARGS_MAX_DEPTH}`,
|
||||
);
|
||||
}
|
||||
if (val === null) return;
|
||||
const t = typeof val;
|
||||
if (t === 'function') {
|
||||
throw new Error(
|
||||
'WorkflowSandbox: args must be JSON-serializable (functions are not allowed).',
|
||||
);
|
||||
}
|
||||
if (t === 'bigint') {
|
||||
throw new Error(
|
||||
'WorkflowSandbox: args must be JSON-serializable (BigInt is not allowed — pass as string).',
|
||||
);
|
||||
}
|
||||
if (t !== 'object') return;
|
||||
const obj = val as object;
|
||||
if (seen.has(obj)) {
|
||||
throw new Error(
|
||||
'WorkflowSandbox: args must be JSON-serializable (circular reference detected).',
|
||||
);
|
||||
}
|
||||
seen.add(obj);
|
||||
if (Array.isArray(val)) {
|
||||
for (const item of val) validateArgs(item, depth + 1, seen);
|
||||
} else {
|
||||
for (const v of Object.values(val as Record<string, unknown>)) {
|
||||
validateArgs(v, depth + 1, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorkflowSandbox(opts: SandboxOptions): WorkflowSandbox {
|
||||
const phases: string[] = [];
|
||||
const logs: string[] = [];
|
||||
|
||||
const safeLog = (msg: unknown): void => {
|
||||
if (logs.length < MAX_LOG_LINES) {
|
||||
logs.push(String(msg));
|
||||
} else if (logs.length === MAX_LOG_LINES) {
|
||||
logs.push(`[workflow log truncated at ${MAX_LOG_LINES} lines]`);
|
||||
}
|
||||
};
|
||||
|
||||
const safePhase = (title: string): void => {
|
||||
if (phases.length < MAX_PHASE_ENTRIES) {
|
||||
phases.push(String(title));
|
||||
} else if (phases.length === MAX_PHASE_ENTRIES) {
|
||||
phases.push(
|
||||
`[workflow phases truncated at ${MAX_PHASE_ENTRIES} entries]`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// FIX-Round1-T6: validate args structure (functions/BigInt/circular/depth)
|
||||
// before serialising. Without this, `JSON.stringify({fn: () => {}})` silently
|
||||
// drops the function key.
|
||||
if (opts.args !== undefined) validateArgs(opts.args);
|
||||
const argsJson = opts.args === undefined ? null : JSON.stringify(opts.args);
|
||||
|
||||
// FIX-Round1-T1/T8/T14: build EVERY sandbox global inside the vm-realm
|
||||
// via the init script below. Host-realm objects (Promises returned by host
|
||||
// async functions, Error objects thrown by host code) leak the host Function
|
||||
// constructor through their prototype chains:
|
||||
// `agent("x").constructor.constructor("return process")()` (T8, success path)
|
||||
// `try { throw new Error } catch(e) { e.constructor.constructor(...)() }` (T1)
|
||||
// The fix is to NEVER expose a host object across the vm boundary. Instead
|
||||
// we expose a primitive bridge (functions and strings) on globalThis,
|
||||
// delete it as the first init action, and have the init script build vm-realm
|
||||
// wrappers that internally call the bridge but only return / throw vm-realm
|
||||
// values.
|
||||
const bridge = {
|
||||
argsJson,
|
||||
pushPhase: safePhase,
|
||||
pushLog: safeLog,
|
||||
lastPhase: () => phases[phases.length - 1],
|
||||
hostAgent: opts.dispatch,
|
||||
// The truthy flags distinguish "injected" from "default stub" inside the
|
||||
// init script without leaking the host function itself when not used.
|
||||
hasParallel: !!opts.parallel,
|
||||
hasPipeline: !!opts.pipeline,
|
||||
hasBudget: !!opts.budget,
|
||||
hostParallel: opts.parallel,
|
||||
hostPipeline: opts.pipeline,
|
||||
budgetTotal: opts.budget ? opts.budget.total : null,
|
||||
hostBudgetSpent: opts.budget ? opts.budget.spent.bind(opts.budget) : null,
|
||||
hostBudgetRemaining: opts.budget
|
||||
? opts.budget.remaining.bind(opts.budget)
|
||||
: null,
|
||||
};
|
||||
|
||||
// T22 (PR #4732 R2): sever the host Object.prototype on both the
|
||||
// bridge AND the sandboxGlobals container. Without this,
|
||||
// `globalThis.constructor.constructor("return process")()` inside the
|
||||
// sandbox reaches the host Object → host Function → host process,
|
||||
// bypassing every other vm-realm hardening measure in this file.
|
||||
// PoC confirmed leak prior to fix; regression covered by
|
||||
// "globalThis.constructor cannot reach host process".
|
||||
Object.setPrototypeOf(bridge, null);
|
||||
const sandboxGlobals: { __workflowBridge: typeof bridge } = Object.assign(
|
||||
Object.create(null) as { __workflowBridge: typeof bridge },
|
||||
{ __workflowBridge: bridge },
|
||||
);
|
||||
const ctx = vm.createContext(sandboxGlobals);
|
||||
|
||||
// FIX-D + FIX-Round1: build Math, Date, args, all async/sync globals,
|
||||
// and the console object entirely inside the vm-realm. After this init
|
||||
// script completes, `globalThis.__workflowBridge` is deleted so the user
|
||||
// script cannot reach it.
|
||||
vm.runInContext(
|
||||
`(() => {
|
||||
const __b = globalThis.__workflowBridge;
|
||||
delete globalThis.__workflowBridge;
|
||||
|
||||
// --- Math (vm-realm, random throws) ---
|
||||
const realMath = Math;
|
||||
const safeMath = Object.create(null);
|
||||
for (const k of Object.getOwnPropertyNames(realMath)) {
|
||||
if (k === 'random' || k === 'constructor') continue;
|
||||
safeMath[k] = realMath[k];
|
||||
}
|
||||
safeMath.random = () => {
|
||||
throw new Error(
|
||||
'Math.random() is unavailable in workflow scripts (breaks resume). ' +
|
||||
'For N independent samples, include the index in the agent label or prompt.'
|
||||
);
|
||||
};
|
||||
globalThis.Math = safeMath;
|
||||
|
||||
// --- Date (vm-realm function that throws on any access) ---
|
||||
const dateMsg = 'Date.now() / new Date() are unavailable in workflow ' +
|
||||
'scripts (breaks resume). Stamp results after the workflow returns, ' +
|
||||
'or pass timestamps via args.';
|
||||
const safeDate = function Date() { throw new Error(dateMsg); };
|
||||
safeDate.now = () => { throw new Error(dateMsg); };
|
||||
safeDate.UTC = () => { throw new Error(dateMsg); };
|
||||
safeDate.parse = () => { throw new Error(dateMsg); };
|
||||
Object.setPrototypeOf(safeDate, null);
|
||||
Object.defineProperty(safeDate, 'constructor', {
|
||||
value: undefined, writable: false, configurable: false,
|
||||
});
|
||||
globalThis.Date = safeDate;
|
||||
|
||||
// --- args (parsed via vm-realm JSON → vm-realm objects/arrays) ---
|
||||
// FIX-Round1-T2: vm-realm arrays keep their vm-realm Array.prototype,
|
||||
// so for...of, .map, .forEach, spread, destructuring all work — and
|
||||
// their inherited methods' constructors are vm-realm Function, which
|
||||
// cannot reach host process.
|
||||
globalThis.args = __b.argsJson === null ? undefined : JSON.parse(__b.argsJson);
|
||||
|
||||
// --- Wrap a host async function so it returns a vm-realm Promise ---
|
||||
// FIX-Round1-T1/T8/T14: success and failure both cross the boundary
|
||||
// as vm-realm values: resolve with the host's value (a primitive
|
||||
// string for dispatch; vm-realm arrays for parallel/pipeline because
|
||||
// those wrappers will produce vm-realm results); reject with a
|
||||
// freshly-constructed vm-realm Error so e.constructor.constructor
|
||||
// stays in the vm realm.
|
||||
function vmAsync(hostFn) {
|
||||
return function (...vmArgs) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
try {
|
||||
const hostPromise = hostFn.apply(null, vmArgs);
|
||||
hostPromise.then(
|
||||
function (value) { resolve(value); },
|
||||
function (hostErr) {
|
||||
const msg = (hostErr && hostErr.message != null)
|
||||
? String(hostErr.message)
|
||||
: String(hostErr);
|
||||
reject(new Error(msg));
|
||||
}
|
||||
);
|
||||
} catch (hostErr) {
|
||||
const msg = (hostErr && hostErr.message != null)
|
||||
? String(hostErr.message)
|
||||
: String(hostErr);
|
||||
reject(new Error(msg));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// --- phase / log ---
|
||||
globalThis.phase = function phase(title) {
|
||||
__b.pushPhase(String(title));
|
||||
};
|
||||
globalThis.log = function log(msg) {
|
||||
__b.pushLog(msg);
|
||||
};
|
||||
|
||||
// --- console (object with hardened methods, all in vm-realm) ---
|
||||
const safeConsole = Object.create(null);
|
||||
safeConsole.log = function () {
|
||||
const parts = [];
|
||||
for (let i = 0; i < arguments.length; i++) parts.push(String(arguments[i]));
|
||||
__b.pushLog(parts.join(' '));
|
||||
};
|
||||
safeConsole.warn = safeConsole.log;
|
||||
safeConsole.error = safeConsole.log;
|
||||
globalThis.console = safeConsole;
|
||||
|
||||
// --- agent (with runtime allowlist + named throws, all vm-realm) ---
|
||||
// FIX-Round1-T13: throw on any opts key not in the allowlist — catches
|
||||
// typos like { scema: ... } that previously slipped through the
|
||||
// [key:string]: unknown index signature.
|
||||
const KNOWN_AGENT_OPTS = ['label', 'phase', 'schema', 'model', 'isolation', 'agentType'];
|
||||
globalThis.agent = vmAsync(function (prompt, agentOpts) {
|
||||
agentOpts = agentOpts || {};
|
||||
const keys = Object.keys(agentOpts);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
if (KNOWN_AGENT_OPTS.indexOf(k) === -1) {
|
||||
throw new Error(
|
||||
"agent({" + k + "}): unknown option. " +
|
||||
"Known options are: " + KNOWN_AGENT_OPTS.join(', ') + "."
|
||||
);
|
||||
}
|
||||
}
|
||||
if (agentOpts.schema !== undefined) {
|
||||
throw new Error(
|
||||
'agent({schema}) is not supported in P1. ' +
|
||||
'Schema enforcement / StructuredOutput contract is scheduled for P3.'
|
||||
);
|
||||
}
|
||||
if (agentOpts.isolation !== undefined) {
|
||||
throw new Error(
|
||||
"agent({isolation: '" + agentOpts.isolation + "'}) is not supported in P1. " +
|
||||
'Worktree / remote isolation is scheduled for a later phase.'
|
||||
);
|
||||
}
|
||||
if (agentOpts.model !== undefined) {
|
||||
throw new Error(
|
||||
'agent({model}) is not supported in P1. Model override is scheduled for a later phase.'
|
||||
);
|
||||
}
|
||||
if (agentOpts.agentType !== undefined) {
|
||||
throw new Error('agent({agentType}) is not supported in P1.');
|
||||
}
|
||||
if (typeof agentOpts.phase === 'string' && agentOpts.phase.length > 0) {
|
||||
if (__b.lastPhase() !== agentOpts.phase) {
|
||||
__b.pushPhase(agentOpts.phase);
|
||||
}
|
||||
}
|
||||
return __b.hostAgent(prompt, agentOpts);
|
||||
});
|
||||
|
||||
// --- parallel / pipeline ---
|
||||
if (__b.hasParallel) {
|
||||
globalThis.parallel = vmAsync(function (thunks) {
|
||||
return __b.hostParallel(thunks);
|
||||
});
|
||||
} else {
|
||||
globalThis.parallel = function parallel() {
|
||||
return new Promise(function (_, reject) {
|
||||
reject(new Error(
|
||||
'parallel() is not supported in P1. Sequential agent() is the only ' +
|
||||
'execution mode in P1. Concurrent fan-out is scheduled for P2.'
|
||||
));
|
||||
});
|
||||
};
|
||||
}
|
||||
if (__b.hasPipeline) {
|
||||
globalThis.pipeline = vmAsync(function (items) {
|
||||
const stages = [];
|
||||
for (let i = 1; i < arguments.length; i++) stages.push(arguments[i]);
|
||||
return __b.hostPipeline.apply(null, [items].concat(stages));
|
||||
});
|
||||
} else {
|
||||
globalThis.pipeline = function pipeline() {
|
||||
return new Promise(function (_, reject) {
|
||||
reject(new Error(
|
||||
'pipeline() is not supported in P1. Staggered multi-stage execution ' +
|
||||
'is scheduled for P2.'
|
||||
));
|
||||
});
|
||||
};
|
||||
}
|
||||
// workflow() always throws in P1.
|
||||
globalThis.workflow = function workflow() {
|
||||
return new Promise(function (_, reject) {
|
||||
reject(new Error(
|
||||
'workflow() (nested workflow invocation) is not supported in P1. ' +
|
||||
'Scheduled for a later phase.'
|
||||
));
|
||||
});
|
||||
};
|
||||
|
||||
// --- budget ---
|
||||
const safeBudget = Object.create(null);
|
||||
Object.defineProperty(safeBudget, 'total', {
|
||||
value: __b.budgetTotal,
|
||||
writable: false, configurable: false,
|
||||
});
|
||||
if (__b.hasBudget) {
|
||||
Object.defineProperty(safeBudget, 'spent', {
|
||||
value: function spent() { return __b.hostBudgetSpent(); },
|
||||
writable: false, configurable: false,
|
||||
});
|
||||
Object.defineProperty(safeBudget, 'remaining', {
|
||||
value: function remaining() { return __b.hostBudgetRemaining(); },
|
||||
writable: false, configurable: false,
|
||||
});
|
||||
} else {
|
||||
Object.defineProperty(safeBudget, 'spent', {
|
||||
value: function spent() {
|
||||
throw new Error(
|
||||
'budget.spent() is not supported in P1. Token tracking is scheduled for P5.'
|
||||
);
|
||||
},
|
||||
writable: false, configurable: false,
|
||||
});
|
||||
Object.defineProperty(safeBudget, 'remaining', {
|
||||
value: function remaining() {
|
||||
throw new Error(
|
||||
'budget.remaining() is not supported in P1. Token tracking is scheduled for P5.'
|
||||
);
|
||||
},
|
||||
writable: false, configurable: false,
|
||||
});
|
||||
}
|
||||
globalThis.budget = safeBudget;
|
||||
})();`,
|
||||
ctx,
|
||||
{ filename: 'workflow-sandbox-init.js' },
|
||||
);
|
||||
|
||||
const maxWallClockMs = resolveMaxWallClockMs(opts);
|
||||
|
||||
return {
|
||||
async run(scriptSource: string): Promise<unknown> {
|
||||
const stripped = stripExportMeta(scriptSource);
|
||||
const wrapped = `(async () => {\n${stripped}\n})()`;
|
||||
const script = new vm.Script(wrapped, {
|
||||
filename: 'workflow.js',
|
||||
});
|
||||
// 30s sync wall-clock cap inside vm — covers `while(true){}` style
|
||||
// synchronous loops only. Once the IIFE hits its first `await`,
|
||||
// `runInContext` returns and this timer is disarmed.
|
||||
const runOpts: vm.RunningScriptOptions = {
|
||||
timeout: 30_000,
|
||||
};
|
||||
const result = script.runInContext(ctx, runOpts) as Promise<unknown>;
|
||||
|
||||
// T23 (PR #4732 R2): async wall-clock cap covers everything past the
|
||||
// first await — `return new Promise(() => {})`, async infinite loops,
|
||||
// hung network calls — none of which the vm timeout or future P5
|
||||
// budget can stop (a 0-token hang spends no budget). Permanent
|
||||
// defense-in-depth; default 30 min, env-tunable.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
// T40 (PR #4732 R4): abort linked controller BEFORE rejecting so
|
||||
// in-flight subagents see the cancellation and stop. Order
|
||||
// matters: rejecting first then aborting would race the
|
||||
// caller's finally block.
|
||||
opts.abortOnTimeout?.abort();
|
||||
reject(
|
||||
new Error(
|
||||
`Workflow execution timed out after ${maxWallClockMs} ms wall clock. ` +
|
||||
'Override via SandboxOptions.maxWallClockMs or QWEN_CODE_MAX_WORKFLOW_SECONDS env var.',
|
||||
),
|
||||
);
|
||||
}, maxWallClockMs);
|
||||
// Don't keep the event loop alive on Node — if the run resolves
|
||||
// quickly, the timer will be cleared in finally; this guards against
|
||||
// edge cases where the caller drops the promise.
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
return await Promise.race([result, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
getPhases: () => [...phases],
|
||||
getLogs: () => [...logs],
|
||||
};
|
||||
}
|
||||
|
|
@ -759,6 +759,7 @@ export interface ConfigParameters {
|
|||
experimentalZedIntegration?: boolean;
|
||||
cronEnabled?: boolean;
|
||||
forkSubagentEnabled?: boolean;
|
||||
workflowsEnabled?: boolean;
|
||||
computerUseEnabled?: boolean;
|
||||
emitToolUseSummaries?: boolean;
|
||||
listExtensions?: boolean;
|
||||
|
|
@ -1144,6 +1145,7 @@ export class Config {
|
|||
private readonly experimentalZedIntegration: boolean = false;
|
||||
private readonly cronEnabled: boolean = false;
|
||||
private readonly forkSubagentEnabled: boolean = false;
|
||||
private workflowsEnabled = false;
|
||||
private readonly computerUseEnabled: boolean = true;
|
||||
private readonly emitToolUseSummaries: boolean = true;
|
||||
private readonly chatRecordingEnabled: boolean;
|
||||
|
|
@ -1328,6 +1330,7 @@ export class Config {
|
|||
params.experimentalZedIntegration ?? false;
|
||||
this.cronEnabled = params.cronEnabled ?? false;
|
||||
this.forkSubagentEnabled = params.forkSubagentEnabled ?? false;
|
||||
this.workflowsEnabled = params.workflowsEnabled ?? false;
|
||||
this.computerUseEnabled = params.computerUseEnabled ?? true;
|
||||
this.emitToolUseSummaries = params.emitToolUseSummaries ?? true;
|
||||
this.listExtensions = params.listExtensions ?? false;
|
||||
|
|
@ -3318,6 +3321,19 @@ export class Config {
|
|||
if (process.env['QWEN_CODE_ENABLE_FORK_SUBAGENT'] === '1') return true;
|
||||
return this.forkSubagentEnabled;
|
||||
}
|
||||
|
||||
isWorkflowsEnabled(): boolean {
|
||||
// Workflows are experimental and opt-in: enabled via settings or env var
|
||||
// P1 also honors a kill switch: QWEN_CODE_DISABLE_WORKFLOWS=1 forces off
|
||||
if (process.env['QWEN_CODE_DISABLE_WORKFLOWS'] === '1') return false;
|
||||
if (process.env['QWEN_CODE_ENABLE_WORKFLOWS'] === '1') return true;
|
||||
return this.workflowsEnabled;
|
||||
}
|
||||
|
||||
setWorkflowsEnabled(enabled: boolean): void {
|
||||
this.workflowsEnabled = enabled;
|
||||
}
|
||||
|
||||
isComputerUseEnabled(): boolean {
|
||||
return this.computerUseEnabled;
|
||||
}
|
||||
|
|
@ -4240,6 +4256,14 @@ export class Config {
|
|||
});
|
||||
}
|
||||
|
||||
// Register workflow tool when enabled
|
||||
if (this.isWorkflowsEnabled()) {
|
||||
await registerLazy(ToolNames.WORKFLOW, async () => {
|
||||
const { WorkflowTool } = await import('../tools/workflow/workflow.js');
|
||||
return new WorkflowTool(this);
|
||||
});
|
||||
}
|
||||
|
||||
// Register computer-use tools unless disabled. All 9 are deferred —
|
||||
// they surface only via ToolSearch keyword match
|
||||
// (see packages/core/src/tools/computer-use/).
|
||||
|
|
|
|||
170
packages/core/src/config/config.workflow-registration.test.ts
Normal file
170
packages/core/src/config/config.workflow-registration.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
// Shared mocks needed by Config constructor.
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../telemetry/index.js', () => ({
|
||||
QwenLogger: vi.fn().mockImplementation(() => ({
|
||||
logStartSessionEvent: vi.fn().mockResolvedValue(undefined),
|
||||
logEndSessionEvent: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
DEFAULT_TELEMETRY_TARGET: 'none',
|
||||
DEFAULT_OTLP_ENDPOINT: '',
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(false),
|
||||
shutdownTelemetry: vi.fn().mockResolvedValue(undefined),
|
||||
refreshSessionContext: vi.fn(),
|
||||
}));
|
||||
vi.mock('../core/contentGenerator.js', () => ({
|
||||
resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({
|
||||
config: { model: 'test-model', apiKey: 'test-key' },
|
||||
sources: {},
|
||||
}),
|
||||
createContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
createContentGenerator: vi.fn().mockReturnValue({}),
|
||||
AuthType: { API_KEY: 'apiKey' },
|
||||
}));
|
||||
vi.mock('../core/baseLlmClient.js');
|
||||
vi.mock('../core/toolHookTriggers.js', () => ({
|
||||
fireNotificationHook: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.mock('../services/skillManager.js', () => {
|
||||
const SkillManagerMock = vi.fn();
|
||||
SkillManagerMock.prototype.startWatching = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
SkillManagerMock.prototype.refreshCache = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
SkillManagerMock.prototype.stopWatching = vi.fn();
|
||||
SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]);
|
||||
SkillManagerMock.prototype.addChangeListener = vi.fn();
|
||||
SkillManagerMock.prototype.removeChangeListener = vi.fn();
|
||||
SkillManagerMock.prototype.matchAndActivateByPath = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
SkillManagerMock.prototype.matchAndActivateByPaths = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
return { SkillManager: SkillManagerMock };
|
||||
});
|
||||
vi.mock('../subagents/subagent-manager.js', () => {
|
||||
const SubagentManagerMock = vi.fn();
|
||||
SubagentManagerMock.prototype.loadSessionSubagents = vi.fn();
|
||||
SubagentManagerMock.prototype.addChangeListener = vi
|
||||
.fn()
|
||||
.mockReturnValue(() => {});
|
||||
SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]);
|
||||
return { SubagentManager: SubagentManagerMock };
|
||||
});
|
||||
vi.mock('../ide/ide-client.js', () => ({
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getConnectionStatus: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../memory/const.js', () => ({
|
||||
setGeminiMdFilename: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import type { ConfigParameters } from './config.js';
|
||||
import { Config } from './config.js';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
|
||||
const baseParams: ConfigParameters = {
|
||||
cwd: '/tmp',
|
||||
targetDir: '/tmp',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
telemetry: { enabled: false },
|
||||
usageStatisticsEnabled: false,
|
||||
overrideExtensions: [],
|
||||
};
|
||||
|
||||
describe('WorkflowTool registration', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
(fs.existsSync as Mock).mockReturnValue(true);
|
||||
(fs.readdirSync as Mock).mockReturnValue([]);
|
||||
(fs.statSync as Mock).mockReturnValue({
|
||||
isDirectory: vi.fn().mockReturnValue(true),
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => String(p));
|
||||
(fs.mkdirSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.writeFileSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.renameSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.copyFileSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.unlinkSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [
|
||||
'QWEN_CODE_ENABLE_WORKFLOWS',
|
||||
'QWEN_CODE_DISABLE_WORKFLOWS',
|
||||
]) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = originalEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is NOT registered when isWorkflowsEnabled() is false', async () => {
|
||||
delete process.env['QWEN_CODE_ENABLE_WORKFLOWS'];
|
||||
delete process.env['QWEN_CODE_DISABLE_WORKFLOWS'];
|
||||
const cfg = new Config({ ...baseParams });
|
||||
const registry = await cfg.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
});
|
||||
expect(registry.getAllToolNames()).not.toContain(ToolNames.WORKFLOW);
|
||||
});
|
||||
|
||||
it('is registered when isWorkflowsEnabled() is true', async () => {
|
||||
delete process.env['QWEN_CODE_DISABLE_WORKFLOWS'];
|
||||
process.env['QWEN_CODE_ENABLE_WORKFLOWS'] = '1';
|
||||
const cfg = new Config({ ...baseParams });
|
||||
const registry = await cfg.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
});
|
||||
expect(registry.getAllToolNames()).toContain(ToolNames.WORKFLOW);
|
||||
});
|
||||
|
||||
it('QWEN_CODE_DISABLE_WORKFLOWS=1 overrides enable-via-settings', async () => {
|
||||
process.env['QWEN_CODE_DISABLE_WORKFLOWS'] = '1';
|
||||
delete process.env['QWEN_CODE_ENABLE_WORKFLOWS'];
|
||||
const cfg = new Config({ ...baseParams });
|
||||
cfg.setWorkflowsEnabled(true);
|
||||
const registry = await cfg.createToolRegistry(undefined, {
|
||||
skipDiscovery: true,
|
||||
});
|
||||
expect(registry.getAllToolNames()).not.toContain(ToolNames.WORKFLOW);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX-G (Round 4 test Important): regression test for the FIX-8 anti-recursion
|
||||
// guard. A subagent spawned BY a workflow must not be able to call the
|
||||
// Workflow tool again — that would create unbounded O(k^n) fan-out. Without
|
||||
// this assertion, a rename of `ToolNames.WORKFLOW` or accidental deletion of
|
||||
// the exclusion entry would silently open the recursion.
|
||||
describe('Workflow anti-recursion guard', () => {
|
||||
it('ToolNames.WORKFLOW is in EXCLUDED_TOOLS_FOR_SUBAGENTS', async () => {
|
||||
const { EXCLUDED_TOOLS_FOR_SUBAGENTS } = await import(
|
||||
'../agents/runtime/agent-core.js'
|
||||
);
|
||||
expect(EXCLUDED_TOOLS_FOR_SUBAGENTS.has(ToolNames.WORKFLOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
155
packages/core/src/config/config.workflows.test.ts
Normal file
155
packages/core/src/config/config.workflows.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
// Shared mocks needed by Config constructor.
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('../telemetry/index.js', () => ({
|
||||
QwenLogger: vi.fn().mockImplementation(() => ({
|
||||
logStartSessionEvent: vi.fn().mockResolvedValue(undefined),
|
||||
logEndSessionEvent: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
DEFAULT_TELEMETRY_TARGET: 'none',
|
||||
DEFAULT_OTLP_ENDPOINT: '',
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(false),
|
||||
shutdownTelemetry: vi.fn().mockResolvedValue(undefined),
|
||||
refreshSessionContext: vi.fn(),
|
||||
}));
|
||||
vi.mock('../core/contentGenerator.js', () => ({
|
||||
resolveContentGeneratorConfigWithSources: vi.fn().mockReturnValue({
|
||||
config: { model: 'test-model', apiKey: 'test-key' },
|
||||
sources: {},
|
||||
}),
|
||||
createContentGeneratorConfig: vi.fn().mockReturnValue({}),
|
||||
createContentGenerator: vi.fn().mockReturnValue({}),
|
||||
AuthType: { API_KEY: 'apiKey' },
|
||||
}));
|
||||
vi.mock('../core/baseLlmClient.js');
|
||||
vi.mock('../core/toolHookTriggers.js', () => ({
|
||||
fireNotificationHook: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
vi.mock('../services/skillManager.js', () => {
|
||||
const SkillManagerMock = vi.fn();
|
||||
SkillManagerMock.prototype.startWatching = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
SkillManagerMock.prototype.refreshCache = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
SkillManagerMock.prototype.stopWatching = vi.fn();
|
||||
SkillManagerMock.prototype.listSkills = vi.fn().mockResolvedValue([]);
|
||||
SkillManagerMock.prototype.addChangeListener = vi.fn();
|
||||
SkillManagerMock.prototype.removeChangeListener = vi.fn();
|
||||
SkillManagerMock.prototype.matchAndActivateByPath = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
SkillManagerMock.prototype.matchAndActivateByPaths = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
return { SkillManager: SkillManagerMock };
|
||||
});
|
||||
vi.mock('../subagents/subagent-manager.js', () => {
|
||||
const SubagentManagerMock = vi.fn();
|
||||
SubagentManagerMock.prototype.loadSessionSubagents = vi.fn();
|
||||
SubagentManagerMock.prototype.addChangeListener = vi
|
||||
.fn()
|
||||
.mockReturnValue(() => {});
|
||||
SubagentManagerMock.prototype.listSubagents = vi.fn().mockResolvedValue([]);
|
||||
return { SubagentManager: SubagentManagerMock };
|
||||
});
|
||||
vi.mock('../ide/ide-client.js', () => ({
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getConnectionStatus: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('../memory/const.js', () => ({
|
||||
setGeminiMdFilename: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import type { ConfigParameters } from './config.js';
|
||||
import { Config } from './config.js';
|
||||
|
||||
const baseParams: ConfigParameters = {
|
||||
cwd: '/tmp',
|
||||
targetDir: '/tmp',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
telemetry: { enabled: false },
|
||||
usageStatisticsEnabled: false,
|
||||
overrideExtensions: [],
|
||||
};
|
||||
|
||||
describe('Config workflows feature gate', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
(fs.existsSync as Mock).mockReturnValue(true);
|
||||
(fs.readdirSync as Mock).mockReturnValue([]);
|
||||
(fs.statSync as Mock).mockReturnValue({
|
||||
isDirectory: vi.fn().mockReturnValue(true),
|
||||
});
|
||||
vi.mocked(fs.realpathSync).mockImplementation((p) => String(p));
|
||||
(fs.mkdirSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.writeFileSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.renameSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.copyFileSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.unlinkSync as Mock).mockImplementation(() => undefined);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars touched by tests
|
||||
for (const key of [
|
||||
'QWEN_CODE_ENABLE_WORKFLOWS',
|
||||
'QWEN_CODE_DISABLE_WORKFLOWS',
|
||||
]) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = originalEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to disabled', () => {
|
||||
delete process.env['QWEN_CODE_ENABLE_WORKFLOWS'];
|
||||
delete process.env['QWEN_CODE_DISABLE_WORKFLOWS'];
|
||||
const cfg = new Config({ ...baseParams });
|
||||
expect(cfg.isWorkflowsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('respects QWEN_CODE_ENABLE_WORKFLOWS=1', () => {
|
||||
delete process.env['QWEN_CODE_DISABLE_WORKFLOWS'];
|
||||
process.env['QWEN_CODE_ENABLE_WORKFLOWS'] = '1';
|
||||
const cfg = new Config({ ...baseParams });
|
||||
expect(cfg.isWorkflowsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
// TST-I1: "respects setWorkflowsEnabled(true)" was deleted — it is a
|
||||
// getter/setter tautology that tests no logic.
|
||||
|
||||
it('QWEN_CODE_DISABLE_WORKFLOWS=1 overrides everything', () => {
|
||||
process.env['QWEN_CODE_DISABLE_WORKFLOWS'] = '1';
|
||||
process.env['QWEN_CODE_ENABLE_WORKFLOWS'] = '1';
|
||||
const cfg = new Config({ ...baseParams });
|
||||
cfg.setWorkflowsEnabled(true);
|
||||
expect(cfg.isWorkflowsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('honors workflowsEnabled passed via ConfigParameters', () => {
|
||||
const cfg = new Config({ ...baseParams, workflowsEnabled: true });
|
||||
expect(cfg.isWorkflowsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -118,6 +118,10 @@ export type {
|
|||
} from './tools/shell.js';
|
||||
export type { SkillTool, SkillParams } from './tools/skill.js';
|
||||
export type { AgentTool, AgentParams } from './tools/agent/agent.js';
|
||||
export type {
|
||||
WorkflowTool,
|
||||
WorkflowParams,
|
||||
} from './tools/workflow/workflow.js';
|
||||
export type {
|
||||
TodoWriteTool,
|
||||
TodoItem,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export const ToolNames = {
|
|||
COMPUTER_USE_TYPE_TEXT: 'computer_use__type_text',
|
||||
COMPUTER_USE_PRESS_KEY: 'computer_use__press_key',
|
||||
COMPUTER_USE_SET_VALUE: 'computer_use__set_value',
|
||||
WORKFLOW: 'workflow',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
|
@ -101,6 +102,7 @@ export const ToolDisplayNames = {
|
|||
COMPUTER_USE_TYPE_TEXT: 'computer_use__type_text',
|
||||
COMPUTER_USE_PRESS_KEY: 'computer_use__press_key',
|
||||
COMPUTER_USE_SET_VALUE: 'computer_use__set_value',
|
||||
WORKFLOW: 'Workflow',
|
||||
} as const;
|
||||
|
||||
// Migration from old tool names to new tool names
|
||||
|
|
|
|||
213
packages/core/src/tools/workflow/workflow.test.ts
Normal file
213
packages/core/src/tools/workflow/workflow.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { WorkflowTool } from './workflow.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { ToolNames, ToolDisplayNames } from '../tool-names.js';
|
||||
|
||||
function fakeConfig(): Config {
|
||||
return {} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('WorkflowTool', () => {
|
||||
it('has the registered name and display name', () => {
|
||||
const tool = new WorkflowTool(fakeConfig());
|
||||
expect(tool.name).toBe(ToolNames.WORKFLOW);
|
||||
expect(tool.displayName).toBe(ToolDisplayNames.WORKFLOW);
|
||||
});
|
||||
|
||||
it('rejects build() when script is missing', () => {
|
||||
const tool = new WorkflowTool(fakeConfig());
|
||||
expect(() => tool.build({} as never)).toThrow(/script/);
|
||||
});
|
||||
|
||||
it('rejects build() when script is empty string', () => {
|
||||
const tool = new WorkflowTool(fakeConfig());
|
||||
expect(() => tool.build({ script: '' })).toThrow(/script/);
|
||||
});
|
||||
|
||||
it('build() returns an invocation that exposes the script as description', () => {
|
||||
const tool = new WorkflowTool(fakeConfig());
|
||||
const invocation = tool.build({
|
||||
script: 'return 1',
|
||||
});
|
||||
expect(invocation.params.script).toBe('return 1');
|
||||
expect(invocation.getDescription()).toContain('workflow');
|
||||
});
|
||||
|
||||
it('getDefaultPermission returns "ask"', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig());
|
||||
const invocation = tool.build({ script: 'return 1' });
|
||||
expect(await invocation.getDefaultPermission()).toBe('ask');
|
||||
});
|
||||
|
||||
it('execute() runs the script via WorkflowOrchestrator with injected dispatch and returns a ToolResult', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async (prompt) => `T:${prompt}`,
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: `phase("plan");
|
||||
const r = await agent("write hello", { label: "h1" });
|
||||
return r;`,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
const text = JSON.stringify(result.llmContent);
|
||||
expect(text).toContain('T:write hello');
|
||||
// FIX-7: llmContent now contains just the result, not the full JSON wrapper.
|
||||
// The runId should NOT appear in llmContent when the result is a plain string.
|
||||
// (It does appear in returnDisplay, which we don't test here.)
|
||||
expect(JSON.stringify(result.returnDisplay)).toMatch(/wf_[0-9a-f]{16}/);
|
||||
});
|
||||
|
||||
// TST-C3: execute() should return an error result (not throw) when the script throws.
|
||||
it('execute() returns an error result when the script throws', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'unused',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'throw new Error("scripted failure")',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error!.message).toContain('scripted failure');
|
||||
expect(JSON.stringify(result.llmContent)).toContain('Workflow failed');
|
||||
// T4 (PR #4732 R1): assert the machine-readable error type so a
|
||||
// refactor removing the field doesn't go uncaught.
|
||||
expect(result.error!.type).toBe('execution_failed');
|
||||
});
|
||||
|
||||
// T19 (PR #4732 R1): phases / logs accumulated before a script failure
|
||||
// must be included in the user-visible display so debugging is possible.
|
||||
it('execute() includes phases + logs in returnDisplay when script fails', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'unused',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: `
|
||||
phase("plan");
|
||||
log("computing");
|
||||
phase("execute");
|
||||
log("about to fail");
|
||||
throw new Error("boom");
|
||||
`,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeDefined();
|
||||
const display = String(result.returnDisplay);
|
||||
expect(display).toContain('Workflow failed: boom');
|
||||
expect(display).toContain('plan');
|
||||
expect(display).toContain('execute');
|
||||
expect(display).toContain('computing');
|
||||
expect(display).toContain('about to fail');
|
||||
});
|
||||
|
||||
// T12 / T18 (PR #4732 R1): a script that returns a BigInt or a circular
|
||||
// value must not be reported as a workflow failure — the script ran fine,
|
||||
// only the post-processing JSON.stringify hit a limitation.
|
||||
it('execute() degrades gracefully on BigInt return values (success, not failure)', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'unused',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'return 1n + 2n;',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text;
|
||||
expect(llmText).toMatch(/non-JSON-serializable value of type bigint/);
|
||||
});
|
||||
|
||||
it('execute() degrades gracefully on circular return values', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'unused',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'const a = {}; a.self = a; return a;',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text;
|
||||
expect(llmText).toMatch(/non-JSON-serializable value of type object/);
|
||||
});
|
||||
|
||||
// T30 (PR #4732 R3): sibling drift of the R1 T12/T18 fix. llmContent
|
||||
// already degrades per-field on non-serializable result, but the
|
||||
// returnDisplay payload (runId + phases + logs + result) used to be
|
||||
// wrapped in a single JSON.stringify — one bad `result` collapsed the
|
||||
// entire display to "(display payload not JSON-serializable)", losing
|
||||
// the runId, the phases, AND the logs. safeStringifyDisplayPayload now
|
||||
// degrades per-field on the failure path so always-serializable
|
||||
// metadata survives regardless of which field went bad.
|
||||
it('execute() preserves runId/phases/logs in returnDisplay when result is non-JSON-serializable', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'unused',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'phase("compute"); const a = {}; a.self = a; return a;',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
const display = String(result.returnDisplay);
|
||||
// runId, the phase, and a result placeholder must all survive.
|
||||
expect(display).toMatch(/wf_[0-9a-f]{16}/);
|
||||
expect(display).toContain('compute');
|
||||
expect(display).toContain('non-JSON-serializable');
|
||||
// The atomic-failure fallback must NOT appear — that would mean the
|
||||
// whole display payload had thrown.
|
||||
expect(display).not.toContain('display payload not JSON-serializable');
|
||||
});
|
||||
|
||||
// TST-C3: llmContent must be the unwrapped script return value (FIX-7).
|
||||
it('execute() strips the JSON wrapper from llmContent (script return is verbatim)', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'ignored',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'return { kind: "report", body: "hello" };',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const llmText = (result.llmContent as Array<{ text: string }>)[0].text;
|
||||
// The llmText should be the JSON of just the script's return value,
|
||||
// NOT a wrapper with {runId, result, phases, logs}.
|
||||
expect(JSON.parse(llmText)).toEqual({ kind: 'report', body: 'hello' });
|
||||
});
|
||||
|
||||
// FIX-C9 (TST-M2): scripts without an explicit `return` resolve to
|
||||
// undefined. WorkflowTool surfaces a clear placeholder rather than the
|
||||
// literal string "undefined".
|
||||
// FIX-G (Round 4 test Minor): args threading through WorkflowTool.build()
|
||||
// → orchestrator.run() → sandbox. A regression where args is dropped
|
||||
// (e.g. forgetting to pass `args: this.params.args` to orchestrator.run)
|
||||
// would go uncaught.
|
||||
it('execute() threads params.args through to the sandbox args global', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'unused',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'return args.who',
|
||||
args: { who: 'world' },
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text;
|
||||
expect(llmText).toBe('world');
|
||||
});
|
||||
|
||||
it('execute() handles scripts that return undefined (no explicit return)', async () => {
|
||||
const tool = new WorkflowTool(fakeConfig(), {
|
||||
dispatch: async () => 'ignored',
|
||||
});
|
||||
const invocation = tool.build({
|
||||
script: 'phase("noop"); /* no return */',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeUndefined();
|
||||
const llmText = (result.llmContent as Array<{ text: string }>)[0]!.text;
|
||||
expect(llmText).toBe('(workflow returned no value)');
|
||||
});
|
||||
});
|
||||
279
packages/core/src/tools/workflow/workflow.ts
Normal file
279
packages/core/src/tools/workflow/workflow.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview WorkflowTool — user-facing tool that executes a workflow script
|
||||
* via WorkflowOrchestrator (P1: sequential agent dispatch only).
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolInvocation,
|
||||
type ToolResult,
|
||||
type ToolResultDisplay,
|
||||
type ToolLocation,
|
||||
} from '../tools.js';
|
||||
import type { ShellExecutionConfig } from '../../services/shellExecutionService.js';
|
||||
import { ToolNames, ToolDisplayNames } from '../tool-names.js';
|
||||
// FIX-10 (REUSE-I1): import ToolErrorType to use the standard machine-readable
|
||||
// error code rather than an ad-hoc bare `{ message }` object.
|
||||
import { ToolErrorType } from '../tool-error.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
import {
|
||||
WorkflowOrchestrator,
|
||||
WorkflowExecutionError,
|
||||
createProductionDispatch,
|
||||
type WorkflowAgentDispatch,
|
||||
} from '../../agents/runtime/workflow-orchestrator.js';
|
||||
import { createChildAbortController } from '../../utils/abortController.js';
|
||||
|
||||
export interface WorkflowParams {
|
||||
/** Inline JavaScript source for the workflow. Required in P1. */
|
||||
script: string;
|
||||
/** Optional structured value bound to the `args` global inside the script. */
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkflowToolOptions {
|
||||
/**
|
||||
* Test-only dispatch injection. Production callers should leave this
|
||||
* undefined so createProductionDispatch wires real AgentHeadless.
|
||||
*/
|
||||
dispatch?: WorkflowAgentDispatch;
|
||||
}
|
||||
|
||||
const WORKFLOW_PARAM_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
script: {
|
||||
type: 'string',
|
||||
description:
|
||||
'JavaScript source of the workflow. Wrapped as an async IIFE. ' +
|
||||
'May call the injected globals `phase(title)`, `log(msg)`, ' +
|
||||
'`agent(prompt, { label? })`, and read `args`. ' +
|
||||
'P1 ships sequential primitives only — `parallel()` and `pipeline()` ' +
|
||||
'arrive in P2. Writing `Promise.all([agent(), agent()])` spawns ' +
|
||||
'concurrent subagents that share Config and may race on file edits; ' +
|
||||
'prefer `await agent(...)` for ordered execution. ' +
|
||||
'`Date.now()` and `Math.random()` both throw — workflow scripts ' +
|
||||
'must be deterministic for resume. ' +
|
||||
'`export const meta = {...}` declarations are stripped before execution.',
|
||||
},
|
||||
args: {
|
||||
description:
|
||||
'Optional structured value bound to the `args` global. Pass actual JSON, not a stringified value.',
|
||||
},
|
||||
},
|
||||
required: ['script'],
|
||||
} as const;
|
||||
|
||||
class WorkflowToolInvocation extends BaseToolInvocation<
|
||||
WorkflowParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly toolOptions: WorkflowToolOptions,
|
||||
params: WorkflowParams,
|
||||
) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Run a workflow script (${this.params.script.length} chars)`;
|
||||
}
|
||||
|
||||
override toolLocations(): ToolLocation[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
override getDefaultPermission(): Promise<'ask'> {
|
||||
return Promise.resolve('ask');
|
||||
}
|
||||
|
||||
override async execute(
|
||||
signal: AbortSignal,
|
||||
_updateOutput?: (output: ToolResultDisplay) => void,
|
||||
_shellExecutionConfig?: ShellExecutionConfig,
|
||||
): Promise<ToolResult> {
|
||||
// T40 (PR #4732 R4): child controller so dispatch sees caller aborts
|
||||
// AND sandbox.ts wall-clock aborts (see setTimeout handler).
|
||||
const dispatchController = createChildAbortController(signal);
|
||||
const dispatch =
|
||||
this.toolOptions.dispatch ??
|
||||
createProductionDispatch(this.config, dispatchController.signal);
|
||||
const orchestrator = new WorkflowOrchestrator(dispatch);
|
||||
try {
|
||||
const outcome = await orchestrator.run({
|
||||
script: this.params.script,
|
||||
args: this.params.args,
|
||||
abortOnTimeout: dispatchController,
|
||||
});
|
||||
|
||||
// FIX-7 (UP-C2): unwrap the script result so the LLM receives the
|
||||
// script's return value verbatim. The full metadata (runId, phases,
|
||||
// logs) is preserved in returnDisplay for the UI but does not pad
|
||||
// the LLM context with bookkeeping noise.
|
||||
//
|
||||
// T12 / T18 (PR #4732 R1): defensive serialization. A successful
|
||||
// workflow whose `return` value is a BigInt, a circular reference,
|
||||
// or otherwise non-JSON used to be reported as `Workflow failed:
|
||||
// Converting circular structure to JSON` — the script succeeded but
|
||||
// the post-processing crashed. Wrap each JSON.stringify in its own
|
||||
// try/catch with a clear placeholder so a serialization issue
|
||||
// degrades gracefully instead of masquerading as a run failure.
|
||||
const llmText = safeStringifyResult(outcome.result);
|
||||
const displayJson = safeStringifyDisplayPayload({
|
||||
runId: outcome.runId,
|
||||
phases: outcome.phases,
|
||||
logs: outcome.logs,
|
||||
result: outcome.result,
|
||||
});
|
||||
|
||||
return {
|
||||
llmContent: [{ text: llmText }],
|
||||
returnDisplay: '```json\n' + displayJson + '\n```',
|
||||
};
|
||||
} catch (err) {
|
||||
// FIX-H (Round 5 SEC Minor): surface only the message — never the
|
||||
// stack frame — to the LLM and the UI. Caller's stderr/debug log
|
||||
// can still see the full stack via standard logging mechanisms.
|
||||
//
|
||||
// Cross-realm `instanceof Error` is false for vm-realm Errors; use
|
||||
// duck-typed extraction so script-thrown errors aren't coerced to
|
||||
// their "Error: <msg>" toString() form.
|
||||
const message = extractErrorMessage(err);
|
||||
// T19 (PR #4732 R1): if the orchestrator preserved phases / logs
|
||||
// accumulated before the failure, include them in the display so
|
||||
// the user can see what ran before the error.
|
||||
const phases =
|
||||
err instanceof WorkflowExecutionError ? err.phases : undefined;
|
||||
const logs = err instanceof WorkflowExecutionError ? err.logs : undefined;
|
||||
const display =
|
||||
phases || logs
|
||||
? `Workflow failed: ${message}\n\n${safeStringifyDisplayPayload({
|
||||
phases: phases ?? [],
|
||||
logs: logs ?? [],
|
||||
})}`
|
||||
: `Workflow failed: ${message}`;
|
||||
return {
|
||||
llmContent: [{ text: `Workflow failed: ${message}` }],
|
||||
returnDisplay: display,
|
||||
// FIX-10 (REUSE-I1): use the standard ToolErrorType.EXECUTION_FAILED
|
||||
// code so error routing / dashboards can classify workflow failures
|
||||
// the same way as other execution-time tool errors.
|
||||
error: { message, type: ToolErrorType.EXECUTION_FAILED },
|
||||
};
|
||||
} finally {
|
||||
// T40: cancel any straggler subagent on natural completion.
|
||||
dispatchController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T12 / T18 (PR #4732 R1): serialize the script's return value, falling back
|
||||
* to a clear placeholder on BigInt / circular / non-JSON values so a
|
||||
* successful workflow is not reported as a failure.
|
||||
*/
|
||||
function safeStringifyResult(result: unknown): string {
|
||||
if (result === undefined) return '(workflow returned no value)';
|
||||
if (typeof result === 'string') return result;
|
||||
try {
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch {
|
||||
return `(workflow returned a non-JSON-serializable value of type ${typeof result})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T30 (PR #4732 R3): degrade per-field instead of all-or-nothing. The
|
||||
* happy path is one stringify; on failure, walk the top-level keys and
|
||||
* replace each non-serializable value with a placeholder, then
|
||||
* re-stringify. This keeps always-serializable metadata (runId, phases,
|
||||
* logs) visible to the user even when one field (typically `result`)
|
||||
* carries a BigInt / circular value. Future-proof against new payload
|
||||
* fields without requiring caller-side special cases.
|
||||
*/
|
||||
function safeStringifyDisplayPayload(payload: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch {
|
||||
if (payload && typeof payload === 'object') {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
try {
|
||||
JSON.stringify(value);
|
||||
sanitized[key] = value;
|
||||
} catch {
|
||||
sanitized[key] =
|
||||
`(non-JSON-serializable value of type ${typeof value})`;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(sanitized, null, 2);
|
||||
} catch {
|
||||
// Fall through to the generic fallback string below.
|
||||
}
|
||||
}
|
||||
return '(display payload not JSON-serializable)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duck-typed extraction so vm-realm Errors (raised inside the sandbox)
|
||||
* don't coerce to "Error: <msg>" via toString(). See workflow-orchestrator.ts
|
||||
* for the matching helper on the orchestrator side.
|
||||
*/
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
const m = (err as { message: unknown }).message;
|
||||
if (typeof m === 'string') return m;
|
||||
return String(m);
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export class WorkflowTool extends BaseDeclarativeTool<
|
||||
WorkflowParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly toolOptions: WorkflowToolOptions = {},
|
||||
) {
|
||||
super(
|
||||
ToolNames.WORKFLOW,
|
||||
ToolDisplayNames.WORKFLOW,
|
||||
'Execute a workflow script that orchestrates subagents sequentially. ' +
|
||||
'P1 supports `phase`, `log`, and sequential `agent` only. No parallel, ' +
|
||||
'no pipeline, no schema, no resume, no background execution. ' +
|
||||
'Scripts run in a node:vm sandbox without access to the filesystem or ' +
|
||||
'shell; all I/O happens through the spawned agents.',
|
||||
Kind.Other,
|
||||
WORKFLOW_PARAM_SCHEMA,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ false,
|
||||
);
|
||||
}
|
||||
|
||||
protected override validateToolParamValues(
|
||||
params: WorkflowParams,
|
||||
): string | null {
|
||||
if (typeof params.script !== 'string' || params.script.length === 0) {
|
||||
return 'WorkflowTool: `script` parameter is required and must be a non-empty string.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: WorkflowParams,
|
||||
): ToolInvocation<WorkflowParams, ToolResult> {
|
||||
return new WorkflowToolInvocation(this.config, this.toolOptions, params);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue