Beyond the mechanical conflicts (provider capability lines vs master's new inheritsParentContext field; generated catalogs regenerated rather than hand-merged; knip/lockfile), three master-side reworks required semantic adaptation of this branch: - The persona rework removed AgentOptions.systemPrompt, which was the structured-output instruction's channel. The instruction now rides the SAME final-request enforcement listener that injects the schema'd tool: appended per request to final.system (per-request wire state, not agent prompt state). Tests assert the wire request (adapter.requests) instead of child.options; the bare-direct-dispatch test pins the no-system arm. - Tool guidance moved out of deployment prompts into per-tool prompt sections; the examples' workflow paragraph became a tool:<toolName> section contributed by dsh-tool-workflow (explicit-ask-only policy), and both example personas resolve to master's minimal identity+behavior form. tool-workflow gains inject: systemPrompt (+ peer dep, tsconfig ref); the export-shape guard updated. - The uniform-RFC-format gate: the dynamic-workflows RFC restructured to the implemented/ skeleton (bare Status line; Proposal -> Decision; What-was-rejected -> Alternatives considered; new Consequences), and the overall-run-timeout deferral is now recorded in the RFC's Deferred list. The doc-graphs atlas classification gains the workflows seam (workflow-vm implementation, tool-workflow consumer). Master's harness-identity section made "empty assembled prompt" states unreachable through the loop, so the instruction-append is a plain undefined-ternary and the structured tests assert append-not-replace. All snapshot goldens (including workflow-run) replay unchanged. Full local CI-equivalent gate sequence green on the merged tree.
@deepseek-ai/dsh-workflow-vm
The first WorkflowService implementation: an in-process node:vm engine. It parses the Claude Code-format script (export const meta = {...} + plain-JS body), runs the body in a fresh vm context with the workflow hooks injected, and fans agent() calls out to ctx.subagents.
Trust premise
Workflow scripts are model-written — the same trust level as the model's existing bash access — so this engine defends against buggy scripts, never hostile ones. vm is NOT a security boundary and no attempt is made to contain adversarial values: property reads on script values may run script code (a getter, a toString, a proxy trap) on the host stack, and a script determined to hang the process can simply spin past its first await (see the limitations below). What the engine DOES guarantee, because benign scripts hit these constantly: result never rejects, a dropped hook promise never becomes an unhandled rejection (the app boot layer exits the process on those), values that JSON cannot carry are rejected loud instead of silently mangled, and hook misuse is fatal instead of dissolving into a per-item null. Genuine sandboxing is an engine swap behind the seam (worker-thread/isolated-vm, where the boundary is serialization by construction), not incremental host-side defenses here.
The script contract it executes
- Meta extraction (
extractMeta): a string/comment-aware brace scanner finds the leadingexport const metaliteral (template interpolation rejected — the literal must be pure), evaluates it ALONE in an empty timed vm context, materializes the result to plain JSON data, validates the shape (name/descriptionrequired; unknown fields rejected loud), and blanks the statement line-preservingly so error stacks keep the script's own line numbers. - Hooks:
agent(prompt, {label, phase, schema, model})(schema = the structured-output subset, forwarded asoutputSchema; result = validated object, or final text without a schema; a failed child resolvesnull),parallel(thunks),pipeline(items, ...stages)with NO cross-stage barrier and(prev, item, index)stage callbacks,phase(title),log(message), and theargsglobal. Anything else —effort/isolation/agentType, unknown options, malformed arguments, schemas outside the subset — throws a FATALWorkflowErrorthatparallel/pipelinere-throw rather than nulling (see the seam README's failure discipline). - Determinism bans:
Date.now(),Math.random(), and arglessnew Date()throw (kept even though resume is deferred, so scripts stay resume-compatible); no timers, filesystem, or Node APIs exist in the context.
The value boundary
Values ENTERING the host (the meta literal, hook options/schemas, the script's return) are materialized by materializeFromRealm: a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested undefined), copying into host containers via defineProperty so a "__proto__" key becomes a data property, never a prototype mutation. Getters are read ordinarily — the RESULT is what crosses; a read that throws fails loud. Values ENTERING the realm (args, agent() results, hook promises and their failures, combinator arrays) are handed over directly as host values — the script is trusted, so host prototypes are not a leak; args is structuredCloned once at start so a script scribbling on it cannot mutate the caller's object. One script-visible consequence: an error thrown by a hook is a HOST error, so e instanceof Error inside the script is false — branch on e.name/e.code instead (the combinators recognize fatality by host instanceof, which a script-built object can never pass, so fatal-vs-null cannot be forged or dissolved).
Limits, cancellation, disposal
Per-run: a concurrency semaphore (maxConcurrentAgents), a total-agent() cap (maxTotalAgents), and a per-call item cap (maxItemsPerCall), all config. cancel() aborts every child (a shared AbortSignal), rejects waiting agent() slots, and makes every future hook call throw CANCELLED — the script dies at its next await and the run settles cancelled; a cancellation that lands before the body runs (or before it settles) reports cancelled even if the script itself needed no hooks, and a script that STILL has not settled disposeGraceMs after the cancel (parked on a promise no hook owns, like await new Promise(() => {})) is ABANDONED with result force-settling cancelled — a consumer awaiting result is never wedged past a cancellation. Once a run settles, stray children a script fired without awaiting are aborted too, and dispose() waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection; thrown script values are rendered by a total host-side renderer (stack, then message, then String(), with a fixed label if rendering itself throws) — result cannot reject.
Documented limitations (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): start() runs the script's initial synchronous slice inline, so the caller blocks until the first await or the vm timeout; that timeout covers ONLY the initial slice, so a synchronous spin past it (an await continuation, a thenable's then invoked by promise resolution, or script code the host runs while rendering a thrown value) cannot be killed; dispose() waits disposeGraceMs then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop). A returned promise or thenable resolves per JavaScript semantics BEFORE materialization — that is what makes an un-awaited return agent('x') work — and the value-boundary guard applies to the resolution.
Config
| Key | Default | Meaning |
|---|---|---|
provider |
spawn |
The ctx.subagents provider children run on. |
maxConcurrentAgents |
0 (auto) |
Concurrent agent() ceiling; 0 resolves to min(16, max(1, cores - 2)). |
maxTotalAgents |
1000 |
Total agent() calls one run may start (runaway-loop backstop). |
maxItemsPerCall |
4096 |
Items accepted by one parallel()/pipeline() call. |
syncTimeoutMs |
5000 |
vm timeout for the initial synchronous slice and the meta evaluation. |
disposeGraceMs |
5000 |
How long dispose() waits for a cancelled script and its children before abandoning them. |