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,224 @@
|
||||
/**
|
||||
* The workflow capability seam (`ctx.workflows`): an abstract service defining
|
||||
* WHAT a workflow engine does — execute a model-written orchestration script
|
||||
* that fans out subagents — without saying HOW. Implementations subclass
|
||||
* {@link WorkflowService} and register as the `workflows` service (one
|
||||
* implementation per context, cordis' standard duplicate-service behavior);
|
||||
* `@deepseek-ai/dsh-workflow-vm` (an in-process `node:vm` engine) is the
|
||||
* first. Future engines (a worker-thread or isolated-vm sandbox) swap in
|
||||
* without touching the model-facing tool that consumes them
|
||||
* (`@deepseek-ai/dsh-tool-workflow`).
|
||||
*
|
||||
* The `workflow/*` lifecycle events are OBSERVE-ONLY data snapshots: they
|
||||
* carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun}
|
||||
* — a listener must not gain `cancel`/`dispose`; control stays with the
|
||||
* `start()` caller holding the run. Every emit is per-listener contained (a
|
||||
* throwing subscriber is logged, never propagated), so one bad observer can
|
||||
* neither strand a live run nor starve later listeners.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRun,
|
||||
WorkflowRunInfo,
|
||||
WorkflowStartRequest,
|
||||
} from './types.ts'
|
||||
|
||||
export { WorkflowRunId } from './types.ts'
|
||||
export type {
|
||||
WorkflowAgentEndInfo,
|
||||
WorkflowAgentInfo,
|
||||
WorkflowAgentOutcome,
|
||||
WorkflowMeta,
|
||||
WorkflowPhase,
|
||||
WorkflowResult,
|
||||
WorkflowResultInfo,
|
||||
WorkflowRun,
|
||||
WorkflowRunInfo,
|
||||
WorkflowStartRequest,
|
||||
WorkflowStopReason,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workflows: WorkflowService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A workflow run started — the script's meta block validated, the body
|
||||
* about to execute. Paired with {@link Events['workflow/end']}.
|
||||
* @param info - the run's identity snapshot (id + meta).
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/start'(info: WorkflowRunInfo): void
|
||||
/**
|
||||
* The script entered a phase (a `phase(title)` call) — progress grouping
|
||||
* for observers; no execution semantics.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param title - the phase title, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/phase'(info: WorkflowRunInfo, title: string): void
|
||||
/**
|
||||
* The script emitted a narration line (a `log(message)` call).
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param message - the logged message, verbatim.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
/**
|
||||
* One `agent()` call started a child run. Paired with
|
||||
* {@link Events['workflow/agent-end']} by `agent.seq`.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call's sequence number, label, phase, and child id.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
/**
|
||||
* One `agent()` call settled (clean result, child failure, or run
|
||||
* cancellation). Paired with {@link Events['workflow/agent-start']}.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param agent - the call identity plus its outcome.
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
/**
|
||||
* A workflow run settled (any stop reason). Fired when
|
||||
* {@link WorkflowRun.result} resolves. Paired with
|
||||
* {@link Events['workflow/start']}.
|
||||
* @param info - the run's identity snapshot.
|
||||
* @param result - the outcome data (stop reason, error, agent count) —
|
||||
* deliberately WITHOUT the result value (see {@link WorkflowResultInfo}).
|
||||
* @mode emit
|
||||
*/
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
}
|
||||
}
|
||||
|
||||
/** The full set of `workflow/*` event names {@link WorkflowService.emitWorkflowEvent} dispatches. */
|
||||
export type WorkflowEventName =
|
||||
| 'workflow/start'
|
||||
| 'workflow/phase'
|
||||
| 'workflow/log'
|
||||
| 'workflow/agent-start'
|
||||
| 'workflow/agent-end'
|
||||
| 'workflow/end'
|
||||
|
||||
/**
|
||||
* The workflow-seam error codes. Every one of these is FATAL when it reaches
|
||||
* a script (see {@link WorkflowError.fatal}): the combinators re-throw it
|
||||
* instead of dissolving it into an ordinary per-item `null`.
|
||||
*
|
||||
* - `SCRIPT_PARSE` — the script (or its meta statement) does not parse.
|
||||
* - `META_INVALID` — the meta block evaluated but fails the shape contract.
|
||||
* - `INVALID_ARGUMENT` — a hook was called with malformed arguments.
|
||||
* - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support
|
||||
* (deferred: `effort`/`isolation`/`agentType`) or does not know.
|
||||
* - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output
|
||||
* subset (see dsh-tools).
|
||||
* - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped.
|
||||
* - `AGENT_START` — the subagent seam refused to start a child.
|
||||
* - `RESULT_UNSERIALIZABLE` — a value crossing the realm boundary is not
|
||||
* plain JSON data.
|
||||
* - `CANCELLED` — the run was cancelled; pending and future hooks reject
|
||||
* with this (the script-kill mechanism).
|
||||
*/
|
||||
export type WorkflowErrorCode =
|
||||
| 'SCRIPT_PARSE'
|
||||
| 'META_INVALID'
|
||||
| 'INVALID_ARGUMENT'
|
||||
| 'UNSUPPORTED_OPTION'
|
||||
| 'UNSUPPORTED_SCHEMA'
|
||||
| 'AGENT_CAP'
|
||||
| 'ITEM_CAP'
|
||||
| 'AGENT_START'
|
||||
| 'RESULT_UNSERIALIZABLE'
|
||||
| 'CANCELLED'
|
||||
|
||||
/**
|
||||
* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the
|
||||
* `code` is machine-routable taxonomy. `fatal` drives the combinator
|
||||
* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd
|
||||
* option or a tripped cap must kill the script loudly), and reserve the
|
||||
* per-item `null` for child-run failures and ordinary in-stage script errors.
|
||||
* Every {@link WorkflowErrorCode} is fatal in this cut; the flag exists so the
|
||||
* distinction is explicit at every catch site rather than implied.
|
||||
*/
|
||||
export class WorkflowError extends HarnessError {
|
||||
/** Whether combinators must propagate this error instead of nulling the item. */
|
||||
readonly fatal: boolean
|
||||
|
||||
constructor(message: string, code: WorkflowErrorCode, options?: ErrorOptions & { fatal?: boolean }) {
|
||||
super(message, code, options)
|
||||
this.name = 'WorkflowError'
|
||||
this.fatal = options?.fatal ?? true
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether combinators must re-throw `error` instead of mapping the item to `null`. */
|
||||
export function isFatalWorkflowError(error: unknown): boolean {
|
||||
return error instanceof WorkflowError && error.fatal
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract workflow execution service. Subclass, implement {@link start}, and
|
||||
* load the subclass as a plugin — it registers as `ctx.workflows` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior).
|
||||
*
|
||||
* Semantics every implementation must honor:
|
||||
* - {@link start} throws synchronously for a request that cannot begin (an
|
||||
* unparseable script, an invalid meta block). Once it returns a
|
||||
* {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with
|
||||
* `stopReason: 'error'` (or `'cancelled'`).
|
||||
* - The `workflow/*` events fire through {@link emitWorkflowEvent} (data
|
||||
* snapshots, per-listener containment); `workflow/end` fires exactly once
|
||||
* per started run, after `result` is settled or as it settles.
|
||||
* - `dispose()` reaches quiescence within a bounded grace: it cancels, waits
|
||||
* for the script to settle, and abandons a stuck script rather than
|
||||
* hanging its caller (the engine documents what abandonment leaves behind).
|
||||
*/
|
||||
export abstract class WorkflowService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'workflows')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a workflow script.
|
||||
* @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.
|
||||
*/
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
|
||||
/**
|
||||
* Emit one `workflow/*` lifecycle event with PER-LISTENER containment:
|
||||
* dispatch each subscriber individually and log (never propagate) a thrown
|
||||
* one, so one bad subscriber can neither fail the engine mid-run, surface as
|
||||
* an unhandled rejection on a detached settle hook, nor starve the listeners
|
||||
* registered after it (cordis `emit` halts on the first throw — same
|
||||
* guarantee as the subagent seam's lifecycle emits).
|
||||
* @param name - the `workflow/*` event to dispatch.
|
||||
* @param args - the event's payload, matching its declared signature.
|
||||
*/
|
||||
protected emitWorkflowEvent(name: WorkflowEventName, ...args: unknown[]): void {
|
||||
for (const callback of this.ctx.events.dispatch('emit', [name, ...args])) {
|
||||
try {
|
||||
// The declared workflow/* signatures are all void-returning emits; the
|
||||
// dispatch callback applies the payload tuple.
|
||||
;(callback as (...payload: unknown[]) => void)(...args)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`workflow: ${name} listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkflowService
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Workflow seam vocabulary: the request/run/result types a workflow engine
|
||||
* consumes and produces, plus the payload shapes of the `workflow/*` events.
|
||||
* Types only (plus the id-brand factory), per the package convention.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-workflow/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** Identifies one workflow run. */
|
||||
export type WorkflowRunId = Branded<'WorkflowRunId'>
|
||||
|
||||
/** Brand a string as a {@link WorkflowRunId}. */
|
||||
export function WorkflowRunId(id: string): WorkflowRunId {
|
||||
return id as WorkflowRunId
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase declared in a script's `meta.phases` (progress vocabulary only —
|
||||
* phases group agents in observers/UIs; they impose no execution structure).
|
||||
*/
|
||||
export interface WorkflowPhase {
|
||||
/** The phase title; `phase()` calls match against it by exact string. */
|
||||
title: string
|
||||
/** Optional one-line description of what the phase does. */
|
||||
detail?: string
|
||||
/** Optional model override this phase is expected to use (informational). */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The script's `export const meta` block, validated by the engine before the
|
||||
* body runs. `name`/`description` are required; the rest is optional
|
||||
* annotation. Matches the Claude Code dynamic-workflows script format.
|
||||
*/
|
||||
export interface WorkflowMeta {
|
||||
/** Short kebab-case workflow name (display + persistence key). */
|
||||
name: string
|
||||
/** One-line description of what the workflow does. */
|
||||
description: string
|
||||
/** Optional guidance on when this workflow applies (shown in listings). */
|
||||
whenToUse?: string
|
||||
/** Optional phase declarations matched by `phase()` calls. */
|
||||
phases?: WorkflowPhase[]
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller asks for when starting a workflow run. `parent` is REQUIRED —
|
||||
* every `agent()` the script spawns is attributed to it (cwd, lineage, depth
|
||||
* flow through the subagent seam). `args` must be plain host-realm JSON data;
|
||||
* the engine exposes it to the script as the `args` global.
|
||||
*/
|
||||
export interface WorkflowStartRequest {
|
||||
/** The full script text: `export const meta = {...}` + a plain-JS body. */
|
||||
script: string
|
||||
/** Optional input exposed verbatim to the script as the `args` global. */
|
||||
args?: unknown
|
||||
/** The agent on whose behalf the run executes (parent of every child). */
|
||||
parent: Agent
|
||||
/** Cancels the run when aborted (the tool's `exec.signal`). */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a run settled. CLOSED union (engine-owned, consumers may exhaust):
|
||||
* `completed` = the script ran to its final `return`; `cancelled` = the run
|
||||
* was cancelled (caller `cancel()`/signal); `error` = the script threw, a
|
||||
* fatal `WorkflowError` propagated, or the result failed materialization.
|
||||
*/
|
||||
export type WorkflowStopReason = 'completed' | 'cancelled' | 'error'
|
||||
|
||||
/**
|
||||
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
|
||||
* the script's materialized return value (plain host-realm JSON data; `null`
|
||||
* when the script returned `undefined`) — meaningful only for `completed`.
|
||||
* A non-`completed` reason carries the failure in `error`; the consumer maps
|
||||
* it to an `isError` tool result rather than reporting partial output.
|
||||
*/
|
||||
export interface WorkflowResult {
|
||||
/** The script's return value (host JSON data; `null` for no return). */
|
||||
value: unknown
|
||||
/** Why the run settled. */
|
||||
stopReason: WorkflowStopReason
|
||||
/** The failure message (present iff `stopReason` is not `completed`). */
|
||||
error?: string
|
||||
/** How many `agent()` calls the run started (across its whole lifetime). */
|
||||
agentsStarted: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle the consumer holds while a script executes. The consumer awaits
|
||||
* `result`, may `cancel` mid-flight, and MUST `dispose` on every path.
|
||||
* `result` does NOT reject — a script failure resolves with `stopReason:
|
||||
* 'error'` — so the consumer maps a non-`completed` reason to an `isError`
|
||||
* result. `dispose()` cancels, then waits a bounded grace for the script to
|
||||
* settle before abandoning it (the engine documents the abandonment
|
||||
* semantics); it never hangs on a stuck script.
|
||||
*/
|
||||
export interface WorkflowRun {
|
||||
readonly id: WorkflowRunId
|
||||
/** The validated meta block (available before the body runs). */
|
||||
readonly meta: WorkflowMeta
|
||||
readonly result: Promise<WorkflowResult>
|
||||
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await. */
|
||||
cancel(reason?: string): void
|
||||
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Identifying detail for a run, carried by every `workflow/*` event (a data snapshot, never the live run). */
|
||||
export interface WorkflowRunInfo {
|
||||
/** The run's id. */
|
||||
id: WorkflowRunId
|
||||
/** The run's validated meta block. */
|
||||
meta: WorkflowMeta
|
||||
}
|
||||
|
||||
/** One `agent()` call's identity within a run (the `workflow/agent-start` payload). */
|
||||
export interface WorkflowAgentInfo {
|
||||
/** 1-based sequence number of this `agent()` call within the run. */
|
||||
seq: number
|
||||
/** The display label (the `label` option, or a prompt snippet). */
|
||||
label: string
|
||||
/** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */
|
||||
phase?: string
|
||||
/** The child agent's id on the subagent seam. */
|
||||
childId: AgentId
|
||||
}
|
||||
|
||||
/** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */
|
||||
export type WorkflowAgentOutcome = 'completed' | 'failed' | 'cancelled'
|
||||
|
||||
/** One `agent()` call's settlement (the `workflow/agent-end` payload). */
|
||||
export interface WorkflowAgentEndInfo extends WorkflowAgentInfo {
|
||||
/** How the call settled. */
|
||||
outcome: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled run's outcome as event data (the `workflow/end` payload): the
|
||||
* {@link WorkflowResult} minus `value` (a listener observing outcomes must not
|
||||
* receive a mutable alias of the caller's result value; a consumer that needs
|
||||
* the value holds the run and awaits `result`).
|
||||
*/
|
||||
export interface WorkflowResultInfo {
|
||||
/** Why the run settled. */
|
||||
stopReason: WorkflowStopReason
|
||||
/** The failure message (present iff `stopReason` is not `completed`). */
|
||||
error?: string
|
||||
/** How many `agent()` calls the run started. */
|
||||
agentsStarted: number
|
||||
}
|
||||
Reference in New Issue
Block a user