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:
22
packages/workflow/tool-workflow/README.md
Normal file
22
packages/workflow/tool-workflow/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-tool-workflow
|
||||
|
||||
The model-facing **`workflow` tool**: run a JavaScript orchestration script that fans out subagents, and return the script's final value. Pure schema + lifecycle shaping over [`ctx.workflows`](../workflow/README.md) — script parsing, execution, caps, and cancellation live behind the seam, so a hardened engine swaps in without touching what the model sees.
|
||||
|
||||
## What the model sees
|
||||
|
||||
Two parameters: `script` (required — the full `export const meta = {...}` + body text; the tool DESCRIPTION carries the complete authoring contract: hooks, semantics, determinism bans, the supported schema subset) and `args` (optional JSON object exposed to the script as the `args` global; a bare list is wrapped as a field, a deliberate deviation from Claude Code's any-JSON `args` so the wire schema stays honest).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Collection is SYNCHRONOUS this cut (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason — never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. The completed result renders the meta name, the agent count, and the return value as JSON, truncated at `maxResultChars` with an explicit notice.
|
||||
|
||||
## Render intent
|
||||
|
||||
Decided up front (per the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, the name sniffed TEXTUALLY from `args.script` (presentation must be a pure function of args, so it cannot ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `toolName` | `workflow` | The model-facing tool name to register. |
|
||||
| `maxResultChars` | `50000` | Rendered-result ceiling; longer JSON is truncated with a notice. |
|
||||
43
packages/workflow/tool-workflow/package.json
Normal file
43
packages/workflow/tool-workflow/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-workflow",
|
||||
"description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflows",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workflow": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
179
packages/workflow/tool-workflow/src/index.ts
Normal file
179
packages/workflow/tool-workflow/src/index.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The model-facing `workflow` tool: run a JavaScript orchestration script that
|
||||
* fans out subagents, and return the script's final value. Pure schema +
|
||||
* lifecycle shaping — script parsing, execution, caps, and cancellation live
|
||||
* behind `ctx.workflows` (`@deepseek-ai/dsh-workflow`), so a hardened engine
|
||||
* swaps in without touching what the model sees.
|
||||
*
|
||||
* Collection is SYNCHRONOUS this cut (like `dsh-tool-subagent`): `execute`
|
||||
* starts a run and awaits `run.result` inside a `try/finally` that always
|
||||
* disposes the run, so the script and its children are torn down on every
|
||||
* path. A non-`completed` stop reason maps to an `isError` tool result (by
|
||||
* throwing) rather than returning partial output as success. Background
|
||||
* collection is deferred to the cross-tool background redesign.
|
||||
*
|
||||
* Render intent (decided up front, per the render-intent RFC): a `generic`
|
||||
* card whose title carries the script's `meta.name`, sniffed textually from
|
||||
* the args — presentation must be a pure function of `args`, so it cannot ask
|
||||
* the engine to parse.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-workflow
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
|
||||
|
||||
export const name = 'tool-workflow'
|
||||
export const inject = ['tools', 'workflows']
|
||||
|
||||
/** Config: the model-facing tool name plus result rendering caps. */
|
||||
export interface Config {
|
||||
/** The model-facing tool name to register (default `workflow`). */
|
||||
toolName?: string
|
||||
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
|
||||
maxResultChars?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
toolName: z.string().default('workflow'),
|
||||
maxResultChars: z.natural().min(1).default(50_000),
|
||||
})
|
||||
|
||||
/**
|
||||
* The script-authoring contract, embedded in the tool description. This IS the
|
||||
* model-facing spec: the meta block, the hooks and their exact semantics, the
|
||||
* determinism bans, and the supported schema subset.
|
||||
*/
|
||||
const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
|
||||
|
||||
The script MUST begin with \`export const meta = {...}\` — a PURE object literal (no variables, calls, or template interpolation) with required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The body after it is plain JavaScript (NOT TypeScript) running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
|
||||
|
||||
Script-body hooks:
|
||||
- \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), \`model\` (override). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
|
||||
- \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
|
||||
- \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
|
||||
- \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
|
||||
|
||||
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`.
|
||||
|
||||
Constraints: concurrency and total-agent caps apply; \`Date.now()\`, \`Math.random()\`, and argless \`new Date()\` throw (pass timestamps via \`args\`); no filesystem, network, timers, or Node.js APIs — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.`
|
||||
|
||||
type WorkflowCallArgs = { script: string; args?: Record<string, unknown> }
|
||||
|
||||
/** Best-effort meta.name sniff for presentation (pure textual; no evaluation). */
|
||||
function sniffMetaName(script: string): string | undefined {
|
||||
const match = /export\s+const\s+meta\s*=\s*\{[^{}]*?name\s*:\s*(['"`])([^'"`\n]{1,64})\1/.exec(script)
|
||||
return match?.[2]
|
||||
}
|
||||
|
||||
/** The pending-state card: a generic card titled by the script's meta name. */
|
||||
function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView {
|
||||
const name = sniffMetaName(args.script)
|
||||
return {
|
||||
card: 'generic',
|
||||
title: name !== undefined ? `workflow: ${name}` : 'workflow',
|
||||
rawInput: args.script,
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed-state card: keep the pending title; render the result content as-is. */
|
||||
function presentWorkflowResult(args: WorkflowCallArgs, result: { content: ContentBlock[]; isError: boolean }): ToolResultView {
|
||||
void args
|
||||
void result
|
||||
return { card: 'generic' }
|
||||
}
|
||||
|
||||
/** A non-`completed` stop reason means the script did not finish cleanly. */
|
||||
function stopReasonError(result: WorkflowResult): string | undefined {
|
||||
switch (result.stopReason) {
|
||||
case 'completed':
|
||||
return undefined
|
||||
case 'cancelled':
|
||||
return `workflow run was cancelled${result.error !== undefined ? ` (${result.error})` : ''}`
|
||||
case 'error':
|
||||
return `workflow run failed: ${result.error ?? 'unknown error'}`
|
||||
/* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
|
||||
default:
|
||||
return `workflow run ended abnormally (${String(result.stopReason satisfies never)})`
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
|
||||
function renderResult(run: WorkflowRun, result: WorkflowResult, maxChars: number): string {
|
||||
// The engine returns JSON data (null for a valueless script), so stringify never yields undefined.
|
||||
const rendered = JSON.stringify(result.value, null, 2)
|
||||
const clipped = rendered.length > maxChars
|
||||
? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]`
|
||||
: rendered
|
||||
return `workflow "${run.meta.name}" completed (${result.agentsStarted} agent${result.agentsStarted === 1 ? '' : 's'}).\nReturn value:\n${clipped}`
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const maxResultChars = config.maxResultChars ?? 50_000
|
||||
ctx.tools.register(defineTool({
|
||||
name: config.toolName ?? 'workflow',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
script: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The complete workflow script: `export const meta = {...}` followed by the plain-JS body (top-level await allowed; end with `return <json-value>`).',
|
||||
},
|
||||
args: {
|
||||
type: 'object',
|
||||
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
|
||||
},
|
||||
},
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const parent = exec.agent
|
||||
if (!parent) {
|
||||
// The loop sets `exec.agent` for every model-driven call; its absence
|
||||
// means a non-agent caller invoked the tool directly, which has no
|
||||
// parent to attribute the children to. Fail loud rather than guess.
|
||||
throw new Error('workflow tool requires a calling agent (exec.agent was undefined)')
|
||||
}
|
||||
|
||||
// Parse failures (SCRIPT_PARSE/META_INVALID) throw synchronously here
|
||||
// and become isError results via the registry — the model sees the
|
||||
// violation list and can correct the script.
|
||||
const run: WorkflowRun = ctx.workflows.start({
|
||||
script: args.script,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is
|
||||
// aborted while the script is in flight, cancel the whole run. The
|
||||
// engine also receives `signal` directly, but an explicit bridge keeps
|
||||
// the tool's contract local (and covers an engine that ignores it).
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
// `addEventListener` does NOT fire for a signal already aborted before
|
||||
// this line — cancel explicitly in that case.
|
||||
if (exec.signal?.aborted) run.cancel('parent step aborted')
|
||||
|
||||
try {
|
||||
const result = await run.result
|
||||
const error = stopReasonError(result)
|
||||
if (error !== undefined) {
|
||||
// Map a non-clean finish to an isError result (the registry turns a
|
||||
// throw into an isError). Report the reason, not partial output.
|
||||
throw new Error(error)
|
||||
}
|
||||
return [{ type: 'text', text: renderResult(run, result, maxResultChars) }]
|
||||
} finally {
|
||||
exec.signal?.removeEventListener('abort', onAbort)
|
||||
// Always reach run quiescence — never leak a live script or children.
|
||||
await run.dispose()
|
||||
}
|
||||
},
|
||||
presentCall: args => presentWorkflowCall(args),
|
||||
presentResult: (args, result) => presentWorkflowResult(args, result),
|
||||
}))
|
||||
}
|
||||
224
packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
Normal file
224
packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import * as toolWorkflow from '../src/index.ts'
|
||||
|
||||
/** A controllable engine standing in behind ctx.workflows (the tool's only seam). */
|
||||
class StubEngine extends WorkflowService {
|
||||
requests: WorkflowStartRequest[] = []
|
||||
cancels: string[] = []
|
||||
disposed = 0
|
||||
settle!: (result: WorkflowResult) => void
|
||||
startError: Error | undefined
|
||||
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
if (this.startError) throw this.startError
|
||||
this.requests.push(request)
|
||||
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
|
||||
}, { once: true })
|
||||
return {
|
||||
id: WorkflowRunId('run-1'),
|
||||
meta: { name: 'stub-flow', description: 'd' },
|
||||
result,
|
||||
cancel: (reason?: string) => {
|
||||
this.cancels.push(reason ?? 'cancelled')
|
||||
this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
|
||||
},
|
||||
dispose: () => {
|
||||
this.disposed += 1
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config?: { toolName?: string; maxResultChars?: number }) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubEngine)
|
||||
await ctx.plugin(toolWorkflow, config ?? {})
|
||||
const engine = ctx.workflows as StubEngine
|
||||
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
|
||||
return { ctx, engine, parent }
|
||||
}
|
||||
|
||||
const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1"
|
||||
|
||||
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
callId: CallId('call-1'),
|
||||
name: 'workflow',
|
||||
arguments: args,
|
||||
...extra?.agent ? { agent: extra.agent } : {},
|
||||
...extra?.signal ? { signal: extra.signal } : {},
|
||||
})
|
||||
}
|
||||
|
||||
describe('dsh-tool-workflow', () => {
|
||||
it('starts a run with the script/args/parent/signal and renders the completed value', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, { script: SCRIPT, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
expect(engine.requests[0]).toMatchObject({ script: SCRIPT, args: { files: ['a.ts'] }, parent })
|
||||
expect(engine.requests[0]!.signal).toBe(controller.signal)
|
||||
engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(false)
|
||||
const rendered = (result.content[0] as { text: string }).text
|
||||
expect(rendered).toContain('workflow "stub-flow" completed (7 agents)')
|
||||
expect(rendered).toContain('"findings"')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('workflow run failed: script threw: boom')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a cancelled run distinctly (with and without a reason)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)')
|
||||
|
||||
const bare = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(2) })
|
||||
engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
|
||||
expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true)
|
||||
})
|
||||
|
||||
it('an error result without a message renders the unknown-error fallback', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
|
||||
expect(((await pending).content[0] as { text: string }).text).toContain('unknown error')
|
||||
})
|
||||
|
||||
it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
controller.abort()
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.cancels).toContain('parent step aborted')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('applies raw-config fallbacks when loaded without schemastery defaults (direct apply)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubEngine)
|
||||
// Direct apply with an empty RAW config: the `??` fallbacks resolve the
|
||||
// tool name and render cap without schemastery having filled them.
|
||||
toolWorkflow.apply(ctx, {})
|
||||
expect(ctx.tools.get('workflow')).toBeDefined()
|
||||
})
|
||||
|
||||
it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
engine.startError = new Error('script must begin with `export const meta = {...}`')
|
||||
const result = await execute(ctx, { script: 'nope' }, { agent: parent })
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('must begin with')
|
||||
})
|
||||
|
||||
it('requires a calling agent (fails loud without exec.agent)', async () => {
|
||||
const { ctx, engine } = await setup()
|
||||
const result = await execute(ctx, { script: SCRIPT })
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
|
||||
expect(engine.requests.length).toBe(0)
|
||||
})
|
||||
|
||||
it('validates its own arguments via the schema DSL (missing script)', async () => {
|
||||
const { ctx, parent } = await setup()
|
||||
const result = await execute(ctx, {}, { agent: parent })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
|
||||
it('cancels the run when exec.signal is ALREADY aborted at call time', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.cancels).toContain('parent step aborted')
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
|
||||
const { ctx, engine, parent } = await setup({ maxResultChars: 40 })
|
||||
const pending = execute(ctx, { script: SCRIPT }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
|
||||
const rendered = ((await pending).content[0] as { text: string }).text
|
||||
expect(rendered).toContain('[truncated:')
|
||||
expect(rendered.length).toBeLessThan(400)
|
||||
})
|
||||
|
||||
it('registers under a configured toolName and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(StubEngine)
|
||||
const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' })
|
||||
expect(ctx.tools.get('orchestrate')).toBeDefined()
|
||||
expect(ctx.tools.get('workflow')).toBeUndefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('orchestrate')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => {
|
||||
const { ctx } = await setup()
|
||||
const tool = ctx.tools.get('workflow')!
|
||||
const view = tool.presentCall!({ script: SCRIPT })
|
||||
expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT })
|
||||
const anonymous = tool.presentCall!({ script: 'export const meta = {}\nreturn 1' })
|
||||
expect(anonymous).toMatchObject({ card: 'generic', title: 'workflow' })
|
||||
})
|
||||
|
||||
it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => {
|
||||
const { ctx } = await setup()
|
||||
const tool = ctx.tools.get('workflow')!
|
||||
expect(tool.presentResult!({ script: SCRIPT }, { content: [], isError: false })).toEqual({ card: 'generic' })
|
||||
// defineTool soft-validates presentation args: a malformed logged shape
|
||||
// falls back to undefined instead of throwing mid-replay.
|
||||
expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
expect('default' in toolWorkflow).toBe(false)
|
||||
expect(toolWorkflow.name).toBe('tool-workflow')
|
||||
expect(toolWorkflow.inject).toEqual(['tools', 'workflows'])
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolWorkflow) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolWorkflow)
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
33
packages/workflow/tool-workflow/tsconfig.json
Normal file
33
packages/workflow/tool-workflow/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../workflow"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user