workflow: dynamic workflows — script-driven multi-agent orchestration
A new capability family at packages/workflow/ in the bash seam shape,
modeled on Claude Code's dynamic workflows: the model writes a JavaScript
orchestration script (export const meta = {...} + plain-JS body), a runtime
executes it, and the script — not the conversation — holds the loop, the
branching, and the intermediate results.
- dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary
(WorkflowRun whose result NEVER rejects) + observe-only workflow/* events
carrying data snapshots (id + meta, never the live run), per-listener
contained like subagent/*.
- dsh-workflow-vm: in-process node:vm engine. Meta extraction via a
string/comment-aware scanner (template interpolation rejected; literal
evaluated alone in an empty timed context; statement blanked line-
preservingly so stacks keep script line numbers). Hooks: agent(prompt,
{label, phase, schema, model}) over ctx.subagents, parallel(), pipeline()
(no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline:
hook misuse (unknown/deferred options, bad arguments, unsupported
schemas, tripped caps, seam start failures, cancellation) throws fatal
WorkflowErrors the combinators RE-THROW — never dissolved into the
per-item null reserved for child failures. Realm boundary: inbound values
materialized by descriptor walks that never invoke accessors (defineProperty
copies, __proto__-safe); outbound values rebuilt in-realm via the
context's own JSON.parse. Determinism bans (Date.now/Math.random/argless
new Date) kept so future resume support cannot break scripts. Caps and
timeouts are validated Config. Every hook promise carries a no-op
rejection consumer (app-boot exits on unhandled rejections).
- dsh-tool-workflow: the model-facing workflow tool, synchronous like
dsh-tool-subagent (start → await → try/finally dispose; abort bridged;
non-completed → isError). Generic render card titled by a textual
meta.name sniff. The tool description carries the authoring contract.
Wired into examples/{coding-agent,acp-agent} with explicit-ask-only
guidance. Coverage at every tier: unit (meta scanner, materializer incl.
counting-getter and __proto__ regressions, combinator semantics,
concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon),
integration over the real spawn stack, with-key e2e (real two-phase run +
the tool through the registry pipeline), and a recorded ACP snapshot
scenario (workflow-run, 1 child session). RFC:
docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred
work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's
layout line.
This commit is contained in:
52 files changed
+4459
-109
No files matched your search
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The `node:vm` workflow engine: the first {@link WorkflowService}
|
||||
* implementation. Parses the Claude Code-format script (meta + body), runs the
|
||||
* body in a fresh in-process vm context with the workflow hooks injected, and
|
||||
* fans `agent()` calls out to `ctx.subagents`.
|
||||
*
|
||||
* Engine limitations, documented as the accepted cost of the in-process
|
||||
* mechanism (the interface/implementation seam exists precisely so a
|
||||
* worker-thread or isolated-vm engine can swap in if these ever matter):
|
||||
*
|
||||
* - vm is NOT a security boundary. Scripts are model-written — the same trust
|
||||
* level as the model's bash access — and the realm-boundary materialization
|
||||
* is correctness containment, not a sandbox.
|
||||
* - The vm `timeout` covers only the initial SYNCHRONOUS slice of the script;
|
||||
* a pathological synchronous spin after the first await cannot be killed
|
||||
* in-process. `dispose()` therefore waits a bounded grace and then ABANDONS
|
||||
* a stuck script: its pending hook promises are already rejected and its
|
||||
* settlement is contained (no unhandled rejection), but an abandoned
|
||||
* synchronous spin would still occupy the event loop.
|
||||
*
|
||||
* Plugin export shape: a default-exported {@link WorkflowService} subclass
|
||||
* (the class-based service form, like `dsh-bash-local`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import WorkflowService, { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowResult, WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import { extractMeta } from './meta.ts'
|
||||
import { WorkflowExecution, type ExecutionLimits } from './runtime.ts'
|
||||
|
||||
export { extractMeta, type ExtractedScript } from './meta.ts'
|
||||
export { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
export { WorkflowExecution, type ExecutionLimits, type ExecutionObserver } from './runtime.ts'
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** The `ctx.subagents` provider children run on (default `spawn`). */
|
||||
provider?: string
|
||||
/** Concurrent `agent()` ceiling; `0` (the default) auto-resolves to `min(16, max(1, cores - 2))`. */
|
||||
maxConcurrentAgents?: number
|
||||
/** Total `agent()` calls one run may start — the runaway-loop backstop (default 1000). */
|
||||
maxTotalAgents?: number
|
||||
/** Items accepted by a single `parallel()`/`pipeline()` call (default 4096). */
|
||||
maxItemsPerCall?: number
|
||||
/** vm timeout for the script's initial synchronous slice AND the meta-literal evaluation (default 5000 ms). */
|
||||
syncTimeoutMs?: number
|
||||
/** How long `dispose()` waits for a cancelled script to settle before abandoning it (default 5000 ms). */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* The vm engine service. `start()` validates the script up front (meta +
|
||||
* body compile) and returns a {@link WorkflowRun} whose `result` never
|
||||
* rejects; the `workflow/*` events fire around the run per the seam contract.
|
||||
*/
|
||||
export class VmWorkflowEngine extends WorkflowService {
|
||||
static inject = ['subagents']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
provider: z.string().default('spawn'),
|
||||
maxConcurrentAgents: z.natural().default(0),
|
||||
maxTotalAgents: z.natural().min(1).default(1000),
|
||||
maxItemsPerCall: z.natural().min(1).default(4096),
|
||||
syncTimeoutMs: z.natural().min(1).default(5000),
|
||||
disposeGraceMs: z.natural().default(5000),
|
||||
})
|
||||
|
||||
private readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// schemastery (static Config) has already filled the defaulted fields;
|
||||
// the assertion records that resolution, not a hidden fallback.
|
||||
this.config = config as ResolvedConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a workflow script. Throws {@link WorkflowError}
|
||||
* synchronously (`SCRIPT_PARSE`/`META_INVALID`) for a script that cannot
|
||||
* begin; once a run is returned, every failure resolves through
|
||||
* `result.stopReason` instead.
|
||||
* @param request - the script, its `args`, the parent agent, and an
|
||||
* optional cancel signal.
|
||||
* @returns the live run (its `result` resolves when the script settles).
|
||||
*/
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
const { meta, body } = extractMeta(request.script, this.config.syncTimeoutMs)
|
||||
const id = WorkflowRunId(randomUUID())
|
||||
// The event payloads and the run handle get SEPARATE meta clones: a
|
||||
// listener mutating its snapshot must not corrupt the holder's view.
|
||||
const info: WorkflowRunInfo = { id, meta: structuredClone(meta) }
|
||||
const limits: ExecutionLimits = {
|
||||
provider: this.config.provider,
|
||||
maxConcurrentAgents: this.config.maxConcurrentAgents === 0
|
||||
? Math.min(16, Math.max(1, availableParallelism() - 2))
|
||||
: this.config.maxConcurrentAgents,
|
||||
maxTotalAgents: this.config.maxTotalAgents,
|
||||
maxItemsPerCall: this.config.maxItemsPerCall,
|
||||
syncTimeoutMs: this.config.syncTimeoutMs,
|
||||
}
|
||||
const execution = new WorkflowExecution(
|
||||
this.ctx,
|
||||
meta,
|
||||
body,
|
||||
request.parent,
|
||||
request.args,
|
||||
request.signal,
|
||||
limits,
|
||||
{
|
||||
phase: (title) => { this.emitWorkflowEvent('workflow/phase', info, title) },
|
||||
log: (message) => { this.emitWorkflowEvent('workflow/log', info, message) },
|
||||
agentStart: (agent) => { this.emitWorkflowEvent('workflow/agent-start', info, agent) },
|
||||
agentEnd: (agent) => { this.emitWorkflowEvent('workflow/agent-end', info, agent) },
|
||||
},
|
||||
)
|
||||
|
||||
this.emitWorkflowEvent('workflow/start', info)
|
||||
const result: Promise<WorkflowResult> = execution.drive()
|
||||
// `workflow/end` fires as the (never-rejecting) result settles, with the
|
||||
// outcome DATA only — the value stays with the run's holder.
|
||||
void result.then((settled) => {
|
||||
this.emitWorkflowEvent('workflow/end', info, {
|
||||
stopReason: settled.stopReason,
|
||||
...settled.error !== undefined ? { error: settled.error } : {},
|
||||
agentsStarted: settled.agentsStarted,
|
||||
})
|
||||
})
|
||||
|
||||
let disposed: Promise<void> | undefined
|
||||
return {
|
||||
id,
|
||||
meta: structuredClone(meta),
|
||||
result,
|
||||
cancel(reason?: string): void {
|
||||
execution.cancel(reason)
|
||||
},
|
||||
dispose: (): Promise<void> => {
|
||||
// Idempotent: cancel, then wait min(settle, grace). `result` never
|
||||
// rejects, so the race needs no rejection handling; an unsettled
|
||||
// script past the grace is abandoned per the module contract.
|
||||
disposed ??= (async () => {
|
||||
execution.cancel('workflow disposed')
|
||||
await Promise.race([result, sleep(this.config.disposeGraceMs)])
|
||||
})()
|
||||
return disposed
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain timer sleep (the dispose grace); unref'd so it never holds the process open. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
timer.unref()
|
||||
})
|
||||
}
|
||||
|
||||
export default VmWorkflowEngine
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Meta-block extraction: turn a Claude Code-format workflow script —
|
||||
* `export const meta = {...}` followed by a plain-JS body — into a validated
|
||||
* {@link WorkflowMeta} plus the body with the meta statement blanked
|
||||
* line-preservingly (error stacks keep the script's own line numbers).
|
||||
*
|
||||
* The scanner is a small string/comment-aware brace matcher, not a JS parser:
|
||||
* it only has to find the END of the meta object literal, and the literal is
|
||||
* contractually PURE (no interpolation, no computed values). Template strings
|
||||
* are tolerated as plain quotes but `${` inside one is rejected up front —
|
||||
* interpolation is where "literal" stops being checkable by evaluation. The
|
||||
* extracted text is then evaluated ALONE in an empty, timed vm context (a
|
||||
* non-literal reference throws there; an expression can still RUN, so the
|
||||
* result — not the source — is the contract: it must materialize to plain
|
||||
* JSON data and pass the shape validation).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm/meta
|
||||
*/
|
||||
|
||||
import * as vm from 'node:vm'
|
||||
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
|
||||
import { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
|
||||
/** The result of {@link extractMeta}: the validated meta and the runnable body. */
|
||||
export interface ExtractedScript {
|
||||
meta: WorkflowMeta
|
||||
/** The script with the meta statement blanked (newlines preserved). */
|
||||
body: string
|
||||
}
|
||||
|
||||
const META_PREFIX = /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*|\s+)*export\s+const\s+meta\s*=\s*/
|
||||
|
||||
/**
|
||||
* Scan `source` from `start` (an opening `{`) to its matching `}`, aware of
|
||||
* string literals (`'`/`"`/backtick, with escapes) and comments. Returns the
|
||||
* index AFTER the closing brace. Throws `SCRIPT_PARSE` on template
|
||||
* interpolation (`${` inside a backtick string) or an unterminated literal.
|
||||
*/
|
||||
function scanObjectLiteral(source: string, start: number): number {
|
||||
let depth = 0
|
||||
let index = start
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (ch === '/' && source[index + 1] === '/') {
|
||||
const end = source.indexOf('\n', index)
|
||||
index = end === -1 ? source.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (ch === '/' && source[index + 1] === '*') {
|
||||
const end = source.indexOf('*/', index + 2)
|
||||
if (end === -1) throw new WorkflowError('meta block has an unterminated comment', 'SCRIPT_PARSE')
|
||||
index = end + 2
|
||||
continue
|
||||
}
|
||||
if (ch === '\'' || ch === '"' || ch === '`') {
|
||||
index = scanString(source, index, ch)
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[') depth += 1
|
||||
if (ch === '}' || ch === ']') {
|
||||
depth -= 1
|
||||
if (depth === 0) return index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
throw new WorkflowError('meta block is not a balanced object literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
|
||||
/** Scan past one string literal starting at `start` (the quote char); returns the index after the closing quote. */
|
||||
function scanString(source: string, start: number, quote: string): number {
|
||||
let index = start + 1
|
||||
while (index < source.length) {
|
||||
const ch = source.charAt(index)
|
||||
if (ch === '\\') {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (quote === '`' && ch === '$' && source[index + 1] === '{') {
|
||||
throw new WorkflowError('template interpolation (`${...}`) is not allowed in the meta block — meta must be a pure literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
if (ch === quote) return index + 1
|
||||
index += 1
|
||||
}
|
||||
throw new WorkflowError('meta block has an unterminated string literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
|
||||
/** Replace `[from, to)` of `source` with whitespace, preserving every newline (line numbers survive). */
|
||||
function blankSpan(source: string, from: number, to: number): string {
|
||||
const blanked = source.slice(from, to).replace(/[^\n]/g, ' ')
|
||||
return source.slice(0, from) + blanked + source.slice(to)
|
||||
}
|
||||
|
||||
/** Collect shape violations for an evaluated meta value (already materialized to host JSON data). */
|
||||
function validateMetaShape(meta: unknown): { meta?: WorkflowMeta; violations: string[] } {
|
||||
const violations: string[] = []
|
||||
/* v8 ignore next 3 -- defensive: the scanner only extracts a brace-delimited literal, which always evaluates to a plain object */
|
||||
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
|
||||
return { violations: ['meta must be an object literal'] }
|
||||
}
|
||||
const record = meta as Record<string, unknown>
|
||||
const known = new Set(['name', 'description', 'whenToUse', 'phases'])
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!known.has(key)) violations.push(`meta.${key} is not a recognized field (name/description/whenToUse/phases)`)
|
||||
}
|
||||
if (typeof record.name !== 'string' || record.name.length === 0) violations.push('meta.name must be a non-empty string')
|
||||
if (typeof record.description !== 'string' || record.description.length === 0) violations.push('meta.description must be a non-empty string')
|
||||
if (record.whenToUse !== undefined && typeof record.whenToUse !== 'string') violations.push('meta.whenToUse must be a string')
|
||||
const phases: WorkflowPhase[] = []
|
||||
if (record.phases !== undefined) {
|
||||
if (!Array.isArray(record.phases)) {
|
||||
violations.push('meta.phases must be an array')
|
||||
} else {
|
||||
record.phases.forEach((phase, index) => {
|
||||
if (typeof phase !== 'object' || phase === null || Array.isArray(phase)) {
|
||||
violations.push(`meta.phases[${index}] must be an object`)
|
||||
return
|
||||
}
|
||||
const entry = phase as Record<string, unknown>
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!['title', 'detail', 'model'].includes(key)) violations.push(`meta.phases[${index}].${key} is not a recognized field`)
|
||||
}
|
||||
if (typeof entry.title !== 'string' || entry.title.length === 0) violations.push(`meta.phases[${index}].title must be a non-empty string`)
|
||||
if (entry.detail !== undefined && typeof entry.detail !== 'string') violations.push(`meta.phases[${index}].detail must be a string`)
|
||||
if (entry.model !== undefined && typeof entry.model !== 'string') violations.push(`meta.phases[${index}].model must be a string`)
|
||||
if (violations.length === 0) {
|
||||
phases.push({
|
||||
title: entry.title as string,
|
||||
...entry.detail !== undefined ? { detail: entry.detail as string } : {},
|
||||
...entry.model !== undefined ? { model: entry.model as string } : {},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) return { violations }
|
||||
return {
|
||||
violations,
|
||||
meta: {
|
||||
name: record.name as string,
|
||||
description: record.description as string,
|
||||
...record.whenToUse !== undefined ? { whenToUse: record.whenToUse as string } : {},
|
||||
...record.phases !== undefined ? { phases } : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate the leading `export const meta = {...}` statement.
|
||||
* Throws {@link WorkflowError} — `SCRIPT_PARSE` when the statement is missing
|
||||
* or unscannable, `META_INVALID` when the literal evaluates to something
|
||||
* outside the meta contract (non-JSON data, wrong shape, unknown fields).
|
||||
* @param script - the full script text.
|
||||
* @param evalTimeoutMs - the vm timeout for evaluating the extracted literal.
|
||||
* @returns the validated meta and the line-preservingly blanked body.
|
||||
*/
|
||||
export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScript {
|
||||
const match = META_PREFIX.exec(script)
|
||||
if (!match) {
|
||||
throw new WorkflowError('script must begin with `export const meta = {...}` (leading comments allowed)', 'SCRIPT_PARSE')
|
||||
}
|
||||
const literalStart = match[0].length
|
||||
if (script[literalStart] !== '{') {
|
||||
throw new WorkflowError('`export const meta =` must be followed by an object literal', 'SCRIPT_PARSE')
|
||||
}
|
||||
const literalEnd = scanObjectLiteral(script, literalStart)
|
||||
const literal = script.slice(literalStart, literalEnd)
|
||||
|
||||
let evaluated: unknown
|
||||
try {
|
||||
// An EMPTY context: any non-literal reference (a variable, a call) throws
|
||||
// here. The result — data only — is what the contract checks; a getter or
|
||||
// IIFE can still run, which is why the timeout and the materialization
|
||||
// below are part of the same boundary.
|
||||
evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${String(error)}`, 'META_INVALID', { cause: error })
|
||||
}
|
||||
let data: unknown
|
||||
try {
|
||||
data = materializeFromRealm(evaluated, 'meta')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(`meta block is not pure JSON data — ${error.message}`, 'META_INVALID', { cause: error })
|
||||
}
|
||||
const { meta, violations } = validateMetaShape(data)
|
||||
if (meta === undefined) {
|
||||
throw new WorkflowError(`invalid meta block: ${violations.join('; ')}`, 'META_INVALID')
|
||||
}
|
||||
|
||||
// Blank the whole statement (including a trailing semicolon, if any) so the
|
||||
// body compiles standalone with its original line numbers.
|
||||
let statementEnd = literalEnd
|
||||
while (statementEnd < script.length && (script[statementEnd] === ' ' || script[statementEnd] === '\t')) statementEnd += 1
|
||||
if (script[statementEnd] === ';') statementEnd += 1
|
||||
return { meta, body: blankSpan(script, 0, statementEnd) }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Realm-boundary materialization for the vm engine.
|
||||
*
|
||||
* Values produced INSIDE the script realm (the meta literal, hook arguments,
|
||||
* the script's return value) must become plain host-realm JSON data before the
|
||||
* host touches them. The repo's `isJsonValue` guard cannot run first: it is
|
||||
* prototype-strict (any cross-realm object fails it) and it INVOKES getters
|
||||
* (letting realm code run outside the vm's timed window). So this module walks
|
||||
* own-property DESCRIPTORS — never invoking accessors — and copies data into
|
||||
* host containers, rejecting loud everything JSON cannot carry:
|
||||
* accessor properties, non-plain prototypes, functions, symbols (keys or
|
||||
* values), bigints, non-finite numbers, `undefined` values, cycles, sparse
|
||||
* arrays, and arrays with non-index own properties.
|
||||
*
|
||||
* Host objects are built with `Object.defineProperty` into a fresh `{}` —
|
||||
* never plain `target[key] =` assignment, which a `"__proto__"` key would turn
|
||||
* into prototype mutation instead of a data property.
|
||||
*
|
||||
* The host→realm direction deliberately does NOT live here: a host object
|
||||
* handed into the realm would expose host intrinsics through its prototype
|
||||
* chain, so the engine rebuilds inbound values INSIDE the realm via the
|
||||
* context's own `JSON.parse` (see the runtime).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm/realm
|
||||
*/
|
||||
|
||||
/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */
|
||||
export class MaterializeError extends Error {
|
||||
constructor(public readonly path: string, public readonly reason: string) {
|
||||
super(`${path}: ${reason}`)
|
||||
this.name = 'MaterializeError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an object's prototype chain is data-shaped: `null`, or a prototype
|
||||
* whose own prototype is `null` (the realm's `Object.prototype` — which we
|
||||
* cannot compare by identity across realms). A `Date`/`Map`/class instance
|
||||
* has a longer chain and is rejected.
|
||||
*/
|
||||
function hasPlainPrototype(value: object): boolean {
|
||||
const proto: unknown = Object.getPrototypeOf(value)
|
||||
if (proto === null) return true
|
||||
return Object.getPrototypeOf(proto) === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `value` (typically from the vm realm) into plain host JSON data.
|
||||
* Throws {@link MaterializeError} naming the offending path for anything JSON
|
||||
* cannot carry losslessly. Accessors are detected via descriptors and NEVER
|
||||
* invoked. `undefined` is accepted only at the ROOT (a script with no
|
||||
* `return` value) — the caller decides what it means; an `undefined` nested
|
||||
* INSIDE a container is a violation.
|
||||
* @param value - the realm value to materialize.
|
||||
* @param root - the path label for the root value (error messages).
|
||||
* @returns the host-realm copy (plain objects/arrays/scalars only).
|
||||
*/
|
||||
export function materializeFromRealm(value: unknown, root = 'value'): unknown {
|
||||
if (value === undefined) return undefined
|
||||
return materialize(value, root, new Set())
|
||||
}
|
||||
|
||||
function materialize(value: unknown, path: string, seen: Set<object>): unknown {
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return value
|
||||
case 'number': {
|
||||
if (!Number.isFinite(value)) throw new MaterializeError(path, 'non-finite numbers are not JSON data')
|
||||
return value
|
||||
}
|
||||
case 'bigint':
|
||||
throw new MaterializeError(path, 'bigints are not JSON data')
|
||||
case 'function':
|
||||
throw new MaterializeError(path, 'functions cannot cross the workflow realm boundary')
|
||||
case 'symbol':
|
||||
throw new MaterializeError(path, 'symbols cannot cross the workflow realm boundary')
|
||||
case 'undefined':
|
||||
throw new MaterializeError(path, 'undefined is not JSON data')
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
if (value === null) return null
|
||||
const objectValue: object = value
|
||||
if (seen.has(objectValue)) throw new MaterializeError(path, 'circular references are not JSON data')
|
||||
seen.add(objectValue)
|
||||
try {
|
||||
if (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen)
|
||||
return materializeObject(objectValue, path, seen)
|
||||
} finally {
|
||||
seen.delete(objectValue)
|
||||
}
|
||||
}
|
||||
|
||||
function materializeArray(value: unknown[], path: string, seen: Set<object>): unknown[] {
|
||||
const out: unknown[] = []
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, index)
|
||||
if (descriptor === undefined) throw new MaterializeError(`${path}[${index}]`, 'sparse arrays are not JSON data')
|
||||
if (!('value' in descriptor)) throw new MaterializeError(`${path}[${index}]`, 'accessor properties cannot cross the workflow realm boundary')
|
||||
out.push(materialize(descriptor.value, `${path}[${index}]`, seen))
|
||||
}
|
||||
// Own enumerable props beyond the indices (e.g. `arr.total = 3`) would be
|
||||
// silently dropped by JSON — reject them instead.
|
||||
for (const key of Object.keys(value)) {
|
||||
const index = Number(key)
|
||||
if (!Number.isInteger(index) || index < 0 || index >= value.length) {
|
||||
throw new MaterializeError(`${path}.${key}`, 'arrays with non-index properties are not JSON data')
|
||||
}
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function materializeObject(value: object, path: string, seen: Set<object>): Record<string, unknown> {
|
||||
if (!hasPlainPrototype(value)) {
|
||||
throw new MaterializeError(path, 'only plain objects and arrays are JSON data (exotic prototype)')
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new MaterializeError(path, 'symbol-keyed properties cannot cross the workflow realm boundary')
|
||||
}
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
||||
// Non-enumerable own props never reach JSON output — skip them, matching
|
||||
// JSON.stringify's contract exactly (documented in the module doc).
|
||||
if (!descriptor.enumerable) continue
|
||||
if (!('value' in descriptor)) {
|
||||
throw new MaterializeError(`${path}.${key}`, 'accessor properties cannot cross the workflow realm boundary')
|
||||
}
|
||||
// defineProperty, never assignment: a "__proto__" key must become an OWN
|
||||
// data property of the copy, not a prototype mutation.
|
||||
Object.defineProperty(out, key, {
|
||||
value: materialize(descriptor.value, `${path}.${key}`, seen),
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Per-run execution state for the vm workflow engine: the script context and
|
||||
* its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/`log`/`args`), the
|
||||
* concurrency semaphore and caps, cancellation, and the drive loop that turns
|
||||
* a script settlement into a {@link WorkflowResult}.
|
||||
*
|
||||
* Realm discipline (see also ./realm.ts): values ENTERING the host from the
|
||||
* script (hook options, schemas, the return value) are materialized via
|
||||
* descriptor walks; values ENTERING the realm from the host (`args`, agent()
|
||||
* results) are rebuilt INSIDE the realm through the context's own
|
||||
* `JSON.parse`, so the script never holds an object whose prototype chain
|
||||
* reaches host intrinsics. Realm functions (pipeline stages, parallel thunks)
|
||||
* are called, not materialized — their values stay realm-side.
|
||||
*
|
||||
* Failure discipline: fatal {@link WorkflowError}s (bad hook arguments,
|
||||
* unsupported options/schemas, tripped caps, seam start failures,
|
||||
* cancellation) ALWAYS propagate through `parallel`/`pipeline`; the per-item
|
||||
* `null` is reserved for child-run failures and ordinary in-stage script
|
||||
* errors. Every hook-returned promise gets a no-op rejection consumer
|
||||
* attached, so a script that drops a promise (fires an `agent()` without
|
||||
* awaiting it) cannot surface an unhandled rejection when cancellation
|
||||
* rejects it — the app boot layer exits the process on unhandled rejections.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow-vm/runtime
|
||||
*/
|
||||
|
||||
import * as vm from 'node:vm'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
|
||||
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
|
||||
import { WorkflowError, isFatalWorkflowError } from '@deepseek-ai/dsh-workflow'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowMeta,
|
||||
WorkflowResult,
|
||||
} from '@deepseek-ai/dsh-workflow'
|
||||
import { materializeFromRealm, MaterializeError } from './realm.ts'
|
||||
|
||||
/** The per-run knobs the engine resolves from its Config. */
|
||||
export interface ExecutionLimits {
|
||||
/** The `ctx.subagents` provider name to start children on. */
|
||||
provider: string
|
||||
/** Concurrent `agent()` ceiling (already auto-resolved; ≥ 1). */
|
||||
maxConcurrentAgents: number
|
||||
/** Total `agent()` calls per run (the runaway-loop backstop). */
|
||||
maxTotalAgents: number
|
||||
/** Items accepted by one `parallel()`/`pipeline()` call. */
|
||||
maxItemsPerCall: number
|
||||
/** vm timeout for the script's initial synchronous slice. */
|
||||
syncTimeoutMs: number
|
||||
}
|
||||
|
||||
/** The engine-side observers the execution reports progress through. */
|
||||
export interface ExecutionObserver {
|
||||
phase(title: string): void
|
||||
log(message: string): void
|
||||
agentStart(info: WorkflowAgentInfo): void
|
||||
agentEnd(info: WorkflowAgentEndInfo): void
|
||||
}
|
||||
|
||||
/** The `agent()` options the script may pass; everything else rejects loud. */
|
||||
const SUPPORTED_AGENT_OPTIONS = new Set(['label', 'phase', 'schema', 'model'])
|
||||
/** Deferred Claude Code options we name explicitly in the rejection message. */
|
||||
const DEFERRED_AGENT_OPTIONS = new Set(['effort', 'isolation', 'agentType'])
|
||||
|
||||
/** The in-context prelude that bans the nondeterminism sources (kept even though resume is deferred, so scripts stay resume-compatible). */
|
||||
const DETERMINISM_PRELUDE = `
|
||||
{
|
||||
const banned = (name) => () => {
|
||||
throw new Error(name + ' is not available in workflow scripts (runs must stay deterministic for future resume support; pass timestamps in via args)')
|
||||
}
|
||||
Math.random = banned('Math.random()')
|
||||
Date.now = banned('Date.now()')
|
||||
const RealDate = Date
|
||||
globalThis.Date = new Proxy(RealDate, {
|
||||
construct(target, args, newTarget) {
|
||||
if (args.length === 0) banned('argless new Date()')()
|
||||
return Reflect.construct(target, args, newTarget)
|
||||
},
|
||||
apply: banned('Date()'),
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */
|
||||
function outputText(blocks: ContentBlock[]): string {
|
||||
return blocks
|
||||
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a script failure for the result: prefer the stack (it carries the
|
||||
* script's own line numbers via the compile lineOffset), then the message.
|
||||
* STRUCTURAL detection, not `instanceof Error` — a realm-thrown Error is not
|
||||
* an instance of the host Error class.
|
||||
*/
|
||||
function errorText(error: unknown): string {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const maybe = error as { stack?: unknown; message?: unknown }
|
||||
if (typeof maybe.stack === 'string' && maybe.stack.length > 0) return maybe.stack
|
||||
if (typeof maybe.message === 'string') return maybe.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** A short display label derived from the prompt when the script passes none. */
|
||||
function defaultLabel(prompt: string): string {
|
||||
const newline = prompt.indexOf('\n')
|
||||
const line = newline === -1 ? prompt : prompt.slice(0, newline)
|
||||
return line.length <= 48 ? line : `${line.slice(0, 47)}…`
|
||||
}
|
||||
|
||||
/**
|
||||
* One live script execution. Constructed per run by the engine; `drive()` is
|
||||
* called exactly once and NEVER rejects — every failure becomes a
|
||||
* {@link WorkflowResult} with a non-`completed` stop reason.
|
||||
*/
|
||||
export class WorkflowExecution {
|
||||
/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */
|
||||
private started = 0
|
||||
private activeSlots = 0
|
||||
private readonly slotWaiters: { resolve(): void; reject(error: unknown): void }[] = []
|
||||
private cancelReason: string | undefined
|
||||
private cancelError: WorkflowError | undefined
|
||||
private readonly controller = new AbortController()
|
||||
private currentPhase: string | undefined
|
||||
private readonly context: vm.Context
|
||||
private readonly realmJsonParse: (text: string) => unknown
|
||||
private readonly compiled: vm.Script
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
meta: WorkflowMeta,
|
||||
body: string,
|
||||
private readonly parent: Agent,
|
||||
args: unknown,
|
||||
signal: AbortSignal | undefined,
|
||||
private readonly limits: ExecutionLimits,
|
||||
private readonly observer: ExecutionObserver,
|
||||
) {
|
||||
// Compile FIRST: a body syntax error must throw out of the constructor
|
||||
// (the engine maps it to SCRIPT_PARSE) before any realm state exists.
|
||||
// lineOffset compensates for the wrapper line, so stack traces carry the
|
||||
// script's own line numbers (the meta statement was blanked, not removed).
|
||||
try {
|
||||
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
|
||||
filename: `workflow:${meta.name}`,
|
||||
lineOffset: -1,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
|
||||
}
|
||||
|
||||
this.context = vm.createContext({}, { name: `workflow:${meta.name}` })
|
||||
vm.runInContext(DETERMINISM_PRELUDE, this.context)
|
||||
// The realm's own JSON.parse — the host→realm rebuild channel.
|
||||
const realmJson = vm.runInContext('JSON', this.context) as { parse(text: string): unknown }
|
||||
this.realmJsonParse = (text: string) => realmJson.parse(text)
|
||||
|
||||
const globals: Record<string, unknown> = {
|
||||
agent: (prompt: unknown, opts?: unknown) => this.contain(this.agent(prompt, opts)),
|
||||
parallel: (thunks: unknown) => this.contain(this.parallel(thunks)),
|
||||
pipeline: (items: unknown, ...stages: unknown[]) => this.contain(this.pipeline(items, stages)),
|
||||
phase: (title: unknown) => { this.phase(title) },
|
||||
log: (message: unknown) => { this.log(message) },
|
||||
args: this.toRealm(args),
|
||||
}
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
// Data properties on the contextified global; frozen shape not required —
|
||||
// a script overwriting its own hooks only sabotages itself.
|
||||
;(this.context as Record<string, unknown>)[key] = typeof value === 'function' ? Object.freeze(value) : value
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
this.cancel('workflow start signal already aborted')
|
||||
} else {
|
||||
signal?.addEventListener('abort', () => { this.cancel('workflow signal aborted') }, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run has been cancelled. A METHOD, not an inline property
|
||||
* read: `cancel()` mutates `cancelReason` concurrently (a signal listener,
|
||||
* a raced dispose), and an inline read after an `await` gets narrowed by
|
||||
* control flow into an always-false comparison.
|
||||
*/
|
||||
private isCancelled(): boolean {
|
||||
return this.cancelReason !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the run: children abort (the shared signal), waiting `agent()`
|
||||
* slots reject, and every future hook call throws `CANCELLED` — the script
|
||||
* dies at its next await. Idempotent; the first reason wins.
|
||||
*/
|
||||
cancel(reason?: string): void {
|
||||
if (this.cancelReason !== undefined) return
|
||||
this.cancelReason = reason ?? 'workflow cancelled'
|
||||
this.cancelError = new WorkflowError(`workflow run cancelled: ${this.cancelReason}`, 'CANCELLED')
|
||||
this.controller.abort(this.cancelReason)
|
||||
for (const waiter of this.slotWaiters.splice(0)) waiter.reject(this.cancelledError())
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the script to settlement. Resolves — never rejects — with the run's
|
||||
* {@link WorkflowResult}: the materialized return value on `completed`, the
|
||||
* failure message on `error`, and `cancelled` when the script died of
|
||||
* cancellation. After settlement, any stray children a script fired without
|
||||
* awaiting are aborted (their `agent()` wrappers dispose them).
|
||||
*/
|
||||
async drive(): Promise<WorkflowResult> {
|
||||
try {
|
||||
const scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs }) as Promise<unknown>
|
||||
const raw: unknown = await this.contain(Promise.resolve(scriptPromise))
|
||||
const value = raw === undefined ? null : this.materializeResult(raw)
|
||||
return { value, stopReason: 'completed', agentsStarted: this.started }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WorkflowError && error.code === 'CANCELLED') {
|
||||
return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started }
|
||||
}
|
||||
return { value: null, stopReason: 'error', error: errorText(error), agentsStarted: this.started }
|
||||
} finally {
|
||||
// Reap strays: a script that fired agent() calls without awaiting them
|
||||
// leaves live children behind after settlement — abort them all. (The
|
||||
// per-call wrappers dispose each child; the contain() consumer keeps
|
||||
// their rejections from going unhandled.)
|
||||
if (this.cancelReason === undefined) this.cancel('workflow settled')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a no-op rejection consumer WITHOUT changing what the caller
|
||||
* receives: if the script drops the promise (no await), cancellation cannot
|
||||
* become an unhandled rejection (the app boot layer exits the process on
|
||||
* those); if the script does await it, it still observes the rejection.
|
||||
*/
|
||||
private contain<T>(promise: Promise<T>): Promise<T> {
|
||||
promise.catch(() => { /* consumed: see method contract — a dropped hook promise must not surface an unhandled rejection */ })
|
||||
return promise
|
||||
}
|
||||
|
||||
private cancelledError(): WorkflowError {
|
||||
// cancel() arms cancelError before any caller can observe isCancelled()
|
||||
// === true; the fallback guards the type, not a reachable path.
|
||||
/* v8 ignore next */
|
||||
return this.cancelError ?? new WorkflowError('workflow run cancelled', 'CANCELLED')
|
||||
}
|
||||
|
||||
/** Rebuild a host value inside the script realm (via the realm's own JSON.parse). */
|
||||
private toRealm(value: unknown): unknown {
|
||||
if (value === undefined) return undefined
|
||||
if (value === null) return null
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
|
||||
return this.realmJsonParse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */
|
||||
private materializeResult(raw: unknown): unknown {
|
||||
try {
|
||||
return materializeFromRealm(raw, 'workflow result')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(
|
||||
`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`,
|
||||
'RESULT_UNSERIALIZABLE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire one concurrency slot (FIFO). Cancellation rejects QUEUED waiters
|
||||
* (see {@link cancel}); the callers guard their own entry and post-acquire
|
||||
* windows, so no cancelled-precheck is duplicated here.
|
||||
*/
|
||||
private acquireSlot(): Promise<void> {
|
||||
if (this.activeSlots < this.limits.maxConcurrentAgents) {
|
||||
this.activeSlots += 1
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.slotWaiters.push({
|
||||
resolve: () => {
|
||||
this.activeSlots += 1
|
||||
resolve()
|
||||
},
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private releaseSlot(): void {
|
||||
this.activeSlots -= 1
|
||||
const next = this.slotWaiters.shift()
|
||||
if (next) next.resolve()
|
||||
}
|
||||
|
||||
/** The `agent(prompt, opts)` hook. */
|
||||
private async agent(rawPrompt: unknown, rawOpts: unknown): Promise<unknown> {
|
||||
if (this.isCancelled()) throw this.cancelledError()
|
||||
if (typeof rawPrompt !== 'string' || rawPrompt.length === 0) {
|
||||
throw new WorkflowError('agent() requires a non-empty prompt string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const opts = this.readAgentOptions(rawOpts)
|
||||
if (this.started >= this.limits.maxTotalAgents) {
|
||||
throw new WorkflowError(
|
||||
`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise maxTotalAgents in the engine config if the scale is intentional`,
|
||||
'AGENT_CAP',
|
||||
)
|
||||
}
|
||||
this.started += 1
|
||||
const seq = this.started
|
||||
const label = opts.label ?? defaultLabel(rawPrompt)
|
||||
const phase = opts.phase ?? this.currentPhase
|
||||
|
||||
await this.acquireSlot()
|
||||
try {
|
||||
// No cancelled re-check here: a cancel cannot interleave between a
|
||||
// waiter's resolution and this continuation (single-threaded, no await
|
||||
// between them), and a child started moments after a cancel still dies
|
||||
// via the shared abort signal — the CANCELLED mapping below covers it.
|
||||
let run
|
||||
try {
|
||||
run = this.ctx.subagents.start(this.limits.provider, {
|
||||
prompt: [{ type: 'text', text: rawPrompt }],
|
||||
parent: this.parent,
|
||||
signal: this.controller.signal,
|
||||
...opts.schema !== undefined ? { outputSchema: opts.schema } : {},
|
||||
...opts.model !== undefined ? { agentOptions: { model: opts.model } } : {},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new WorkflowError(`agent() could not start a child on provider "${this.limits.provider}": ${String(error)}`, 'AGENT_START', { cause: error })
|
||||
}
|
||||
const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: run.id }
|
||||
this.observer.agentStart(info)
|
||||
try {
|
||||
const result = await run.result
|
||||
if (result.stopReason === 'completed') {
|
||||
if (opts.schema !== undefined) {
|
||||
// The provider honored outputSchema (capability-gated at start), so
|
||||
// a completed run without a structured value is a child failure.
|
||||
if (result.structured === undefined) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
return null
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
||||
return this.toRealm(result.structured)
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'completed' })
|
||||
return outputText(result.output)
|
||||
}
|
||||
// A cancelled RUN kills the script; a child that failed for its own
|
||||
// reasons resolves null (scripts .filter(Boolean) per the CC contract).
|
||||
if (this.isCancelled()) {
|
||||
this.observer.agentEnd({ ...info, outcome: 'cancelled' })
|
||||
throw this.cancelledError()
|
||||
}
|
||||
this.observer.agentEnd({ ...info, outcome: 'failed' })
|
||||
return null
|
||||
} finally {
|
||||
await run.dispose()
|
||||
}
|
||||
} finally {
|
||||
this.releaseSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize + validate the `agent()` options bag from the realm. */
|
||||
private readAgentOptions(rawOpts: unknown): { label?: string; phase?: string; model?: string; schema?: StructuredOutputSchema } {
|
||||
if (rawOpts === undefined) return {}
|
||||
let opts: unknown
|
||||
try {
|
||||
opts = materializeFromRealm(rawOpts, 'agent() options')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */
|
||||
if (!(error instanceof MaterializeError)) throw error
|
||||
throw new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, 'INVALID_ARGUMENT', { cause: error })
|
||||
}
|
||||
if (typeof opts !== 'object' || opts === null || Array.isArray(opts)) {
|
||||
throw new WorkflowError('agent() options must be an object', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const record = opts as Record<string, unknown>
|
||||
for (const key of Object.keys(record)) {
|
||||
if (SUPPORTED_AGENT_OPTIONS.has(key)) continue
|
||||
if (DEFERRED_AGENT_OPTIONS.has(key)) {
|
||||
throw new WorkflowError(`agent() option "${key}" is deferred and not supported by this engine (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
throw new WorkflowError(`agent() option "${key}" is not recognized (supported: label, phase, schema, model)`, 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
for (const key of ['label', 'phase', 'model'] as const) {
|
||||
if (record[key] !== undefined && typeof record[key] !== 'string') {
|
||||
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
}
|
||||
let schema: StructuredOutputSchema | undefined
|
||||
if (record.schema !== undefined) {
|
||||
try {
|
||||
assertSupportedOutputSchema(record.schema)
|
||||
schema = record.schema
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */
|
||||
if (!(error instanceof OutputSchemaError)) throw error
|
||||
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
|
||||
}
|
||||
}
|
||||
return {
|
||||
...record.label !== undefined ? { label: record.label as string } : {},
|
||||
...record.phase !== undefined ? { phase: record.phase as string } : {},
|
||||
...record.model !== undefined ? { model: record.model as string } : {},
|
||||
...schema !== undefined ? { schema } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */
|
||||
private async parallel(rawThunks: unknown): Promise<unknown[]> {
|
||||
if (!Array.isArray(rawThunks)) {
|
||||
throw new WorkflowError('parallel() requires an array of zero-argument functions', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.assertItemCap(rawThunks.length, 'parallel()')
|
||||
const thunks = rawThunks.map((thunk, index) => {
|
||||
if (typeof thunk !== 'function') {
|
||||
throw new WorkflowError(`parallel() item ${index} is not a function`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
return thunk as () => unknown
|
||||
})
|
||||
return Promise.all(thunks.map(async (thunk) => {
|
||||
try {
|
||||
return await thunk()
|
||||
} catch (error: unknown) {
|
||||
if (isFatalWorkflowError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */
|
||||
private async pipeline(rawItems: unknown, rawStages: unknown[]): Promise<unknown[]> {
|
||||
if (!Array.isArray(rawItems)) {
|
||||
throw new WorkflowError('pipeline() requires an items array', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.assertItemCap(rawItems.length, 'pipeline()')
|
||||
if (rawStages.length === 0) {
|
||||
throw new WorkflowError('pipeline() requires at least one stage function', 'INVALID_ARGUMENT')
|
||||
}
|
||||
const stages = rawStages.map((stage, index) => {
|
||||
if (typeof stage !== 'function') {
|
||||
throw new WorkflowError(`pipeline() stage ${index} is not a function`, 'INVALID_ARGUMENT')
|
||||
}
|
||||
return stage as (previous: unknown, item: unknown, index: number) => unknown
|
||||
})
|
||||
return Promise.all(rawItems.map(async (item: unknown, index) => {
|
||||
let value: unknown = item
|
||||
try {
|
||||
for (const stage of stages) {
|
||||
value = await stage(value, item, index)
|
||||
}
|
||||
return value
|
||||
} catch (error: unknown) {
|
||||
// An ordinary stage throw drops the ITEM to null and skips its
|
||||
// remaining stages; a fatal error kills the whole script.
|
||||
if (isFatalWorkflowError(error)) throw error
|
||||
return null
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
private assertItemCap(length: number, hook: string): void {
|
||||
if (length > this.limits.maxItemsPerCall) {
|
||||
throw new WorkflowError(
|
||||
`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`,
|
||||
'ITEM_CAP',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */
|
||||
private phase(title: unknown): void {
|
||||
if (typeof title !== 'string' || title.length === 0) {
|
||||
throw new WorkflowError('phase() requires a non-empty title string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.currentPhase = title
|
||||
this.observer.phase(title)
|
||||
}
|
||||
|
||||
/** The `log(message)` hook: narration to observers. */
|
||||
private log(message: unknown): void {
|
||||
if (typeof message !== 'string') {
|
||||
throw new WorkflowError('log() requires a message string', 'INVALID_ARGUMENT')
|
||||
}
|
||||
this.observer.log(message)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user