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.
132 lines
5.3 KiB
TypeScript
132 lines
5.3 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService from '@deepseek-ai/dsh-llm'
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
|
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
|
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
|
import SubagentService from '@deepseek-ai/dsh-subagent'
|
|
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn'
|
|
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import VmWorkflowEngine from '../src/index.ts'
|
|
|
|
/**
|
|
* With-key e2e for the workflow engine: a REAL script drives REAL spawn
|
|
* children against the live DeepSeek API — one plain child and one schema'd
|
|
* child through the real structured-output runtime — and the run's value,
|
|
* events, and child sessions are asserted from the outside (never the
|
|
* script's self-report alone). Key-gated (self-skips without
|
|
* DEEPSEEK_API_KEY).
|
|
*/
|
|
|
|
let ctx: Context | undefined
|
|
|
|
afterEach(async () => {
|
|
await ctx?.fiber.dispose()
|
|
ctx = undefined
|
|
})
|
|
|
|
async function harness(): Promise<Context> {
|
|
const built = new Context()
|
|
await built.plugin(LlmService)
|
|
await built.plugin(SessionStore)
|
|
await built.plugin(SystemPrompt)
|
|
await built.plugin(ToolRegistry)
|
|
await built.plugin(AgentRegistry)
|
|
await built.plugin(AgentLoop, { agents: [] })
|
|
await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] })
|
|
await built.plugin(SubagentService)
|
|
await built.plugin(Spawn, { providerName: 'spawn', structuredNudgeRetries: 1 })
|
|
await built.plugin(VmWorkflowEngine, { provider: 'spawn' })
|
|
await built.plugin(ToolWorkflow, {})
|
|
return built
|
|
}
|
|
|
|
const SCRIPT = `export const meta = {
|
|
name: 'e2e-arithmetic',
|
|
description: 'two real children: one prose, one structured',
|
|
phases: [{ title: 'Ask' }, { title: 'Judge' }],
|
|
}
|
|
phase('Ask')
|
|
log('asking the prose child')
|
|
const prose = await agent('Reply with exactly one short sentence: what is 2 + 2?')
|
|
phase('Judge')
|
|
const judged = await agent(
|
|
'Here is an answer to the question "what is 2+2": ' + prose
|
|
+ ' — report whether it contains the number 4 and your confidence between 0 and 1.',
|
|
{ schema: { type: 'object', properties: { containsFour: { type: 'boolean' }, confidence: { type: 'number' } }, required: ['containsFour'] } },
|
|
)
|
|
return { prose, containsFour: judged === null ? null : judged.containsFour }`
|
|
|
|
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workflow engine with-key e2e', () => {
|
|
it('runs a two-phase script over real children, one through the structured runtime', async () => {
|
|
ctx = await harness()
|
|
const parentHandle = ctx.agents.create({
|
|
agentId: AgentId('wf-e2e-parent'),
|
|
sessionId: 'wf-e2e-session' as never,
|
|
agentOptions: { model: 'deepseek-v4-flash' },
|
|
})
|
|
|
|
const events: string[] = []
|
|
const childIds: string[] = []
|
|
for (const name of ['workflow/start', 'workflow/phase', 'workflow/log', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
|
|
ctx.on(name, (...payload: unknown[]) => {
|
|
events.push(name)
|
|
if (name === 'workflow/agent-start') childIds.push((payload[1] as { childId: string }).childId)
|
|
})
|
|
}
|
|
|
|
const run = ctx.workflows.start({ script: SCRIPT, parent: parentHandle.agent })
|
|
const result = await run.result
|
|
await run.dispose()
|
|
|
|
expect(result.stopReason).toBe('completed')
|
|
expect(result.agentsStarted).toBe(2)
|
|
const value = result.value as { prose: string; containsFour: boolean | null }
|
|
// World checks: the prose child really answered (a real completion), and
|
|
// the structured child judged it against the REAL schema-forced tool.
|
|
expect(value.prose.length).toBeGreaterThan(0)
|
|
expect(value.containsFour).toBe(true)
|
|
|
|
expect(events[0]).toBe('workflow/start')
|
|
expect(events.at(-1)).toBe('workflow/end')
|
|
expect(events.filter(name => name === 'workflow/phase').length).toBe(2)
|
|
expect(events.filter(name => name === 'workflow/agent-start').length).toBe(2)
|
|
expect(childIds.length).toBe(2)
|
|
// The children were disposed to quiescence after collection.
|
|
for (const childId of childIds) {
|
|
expect(ctx.agents.get(AgentId(childId))).toBeUndefined()
|
|
}
|
|
await parentHandle.dispose()
|
|
}, 240_000)
|
|
|
|
it('the workflow TOOL runs the same path through the real registry pipeline', async () => {
|
|
ctx = await harness()
|
|
const parentHandle = ctx.agents.create({
|
|
agentId: AgentId('wf-e2e-tool-parent'),
|
|
sessionId: 'wf-e2e-tool-session' as never,
|
|
agentOptions: { model: 'deepseek-v4-flash' },
|
|
})
|
|
|
|
const result = await ctx.tools.execute({
|
|
callId: CallId('wf-e2e-call'),
|
|
name: 'workflow',
|
|
arguments: {
|
|
script: `export const meta = { name: 'e2e-tool', description: 'one real child via the tool' }
|
|
const answer = await agent('Reply with exactly one word: the capital of France.')
|
|
return { answer }`,
|
|
},
|
|
agent: parentHandle.agent,
|
|
})
|
|
|
|
expect(result.isError).toBe(false)
|
|
const text = (result.content[0] as { text: string }).text
|
|
expect(text).toContain('workflow "e2e-tool" completed (1 agent)')
|
|
expect(text.toLowerCase()).toContain('paris')
|
|
await parentHandle.dispose()
|
|
}, 240_000)
|
|
})
|