workflow: meta rides the seam as data — the engine never evaluates it
P1 review finding: extractMeta timed only the literal's vm evaluation;
materializing the RESULT then read properties ordinarily on the HOST
stack, so a meta literal smuggling a getter (get name() { while(true){} })
could wedge the host outside any timeout — defeating the exact spin
isolation the worker thread exists for.
Rather than harden the evaluator (descriptor walks, AST validation),
delete the mechanism: the workflow's identity now reaches the seam as a
plain JSON field (WorkflowStartRequest.meta), carried by the tool as a
schema-validated `meta` object parameter the model fills directly. The
engine only shape-validates data (validateMeta, every violation named)
and pre-parses the body; the scanner, the vm evaluation, and the
host-side materialization are gone, and with them the hole. A body
still opening with a Claude Code-style `export const meta` statement
gets a pointed SCRIPT_PARSE message (the likeliest authoring slip; a
CC script's body stays drop-in, only its meta header moves into the
parameter). syncTimeoutMs now governs exactly one thing: the initial
synchronous slice inside the worker.
The RFC's decision section is rewritten in place (implemented-RFC
rule); the embedded-meta format moves to alternatives-considered with
the hole as the reason. Tool description, presentation (title now reads
meta.name directly — the textual sniff is gone), seam vocabulary docs,
and catalogs follow.
This commit is contained in:
@@ -13,9 +13,8 @@
|
||||
* 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.
|
||||
* card whose title carries the workflow's `meta.name`, read directly from the
|
||||
* call's `meta` parameter — presentation is a pure function of `args`.
|
||||
*
|
||||
* Usage policy ships with the tool as a `tool:<toolName>` system-prompt
|
||||
* section (explicit-ask-only guidance) — tool guidance lives in tool plugins,
|
||||
@@ -58,7 +57,7 @@ type ResolvedConfig = Required<Config>
|
||||
*/
|
||||
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.
|
||||
The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), 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.
|
||||
@@ -70,20 +69,17 @@ Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps
|
||||
|
||||
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — 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]
|
||||
type WorkflowCallArgs = {
|
||||
script: string
|
||||
meta: { name: string; description: string; whenToUse?: string; phases?: { title: string; detail?: string; model?: string }[] }
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The pending-state card: a generic card titled by the script's meta name. */
|
||||
/** The pending-state card: a generic card titled by the workflow's meta name. */
|
||||
function presentWorkflowCall(args: WorkflowCallArgs): ToolCallView {
|
||||
const name = sniffMetaName(args.script)
|
||||
return {
|
||||
card: 'generic',
|
||||
title: name !== undefined ? `workflow: ${name}` : 'workflow',
|
||||
title: `workflow: ${args.meta.name}`,
|
||||
rawInput: args.script,
|
||||
}
|
||||
}
|
||||
@@ -139,7 +135,29 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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>`).',
|
||||
description: 'The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`).',
|
||||
},
|
||||
meta: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'The workflow identity block (plain JSON — never code).',
|
||||
properties: {
|
||||
name: { type: 'string', required: true, description: 'Short kebab-case workflow name.' },
|
||||
description: { type: 'string', required: true, description: 'One-line description of what the workflow does.' },
|
||||
whenToUse: { type: 'string', description: 'Optional guidance on when this workflow applies.' },
|
||||
phases: {
|
||||
type: 'array',
|
||||
description: 'Optional phase declarations matched by phase() calls.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
|
||||
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
|
||||
model: { type: 'string', description: 'Optional model override this phase is expected to use.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
type: 'object',
|
||||
@@ -155,11 +173,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
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.
|
||||
// Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
|
||||
// synchronously here and become isError results via the registry — the
|
||||
// model sees the violation list and can correct the call.
|
||||
const run: WorkflowRun = ctx.workflows.start({
|
||||
script: args.script,
|
||||
meta: args.meta,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
|
||||
@@ -55,7 +55,8 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
|
||||
return { ctx, engine, parent }
|
||||
}
|
||||
|
||||
const SCRIPT = "export const meta = { name: 'audit', description: 'd' }\nreturn 1"
|
||||
const SCRIPT = 'return 1'
|
||||
const META = { name: 'audit', description: 'd' }
|
||||
|
||||
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
@@ -71,9 +72,9 @@ 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 })
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META, 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]).toMatchObject({ script: SCRIPT, meta: META, 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
|
||||
@@ -86,7 +87,7 @@ describe('dsh-tool-workflow', () => {
|
||||
|
||||
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 })
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { 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
|
||||
@@ -97,14 +98,14 @@ describe('dsh-tool-workflow', () => {
|
||||
|
||||
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 })
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { 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 })
|
||||
const bare = execute(ctx, { script: SCRIPT, meta: META }, { 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)
|
||||
@@ -112,7 +113,7 @@ describe('dsh-tool-workflow', () => {
|
||||
|
||||
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 })
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { 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')
|
||||
@@ -121,7 +122,7 @@ describe('dsh-tool-workflow', () => {
|
||||
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 })
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
|
||||
await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
|
||||
controller.abort()
|
||||
const result = await pending
|
||||
@@ -130,17 +131,17 @@ describe('dsh-tool-workflow', () => {
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('a synchronous engine start throw (parse/meta failure) becomes an isError result', async () => {
|
||||
it('a synchronous engine start throw (meta/parse 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 })
|
||||
engine.startError = new Error('invalid meta: meta.name must be a non-empty string')
|
||||
const result = await execute(ctx, { script: 'nope', meta: { name: '', description: 'd' } }, { agent: parent })
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('must begin with')
|
||||
expect((result.content[0] as { text: string }).text).toContain('meta.name must be a non-empty string')
|
||||
})
|
||||
|
||||
it('requires a calling agent (fails loud without exec.agent)', async () => {
|
||||
const { ctx, engine } = await setup()
|
||||
const result = await execute(ctx, { script: SCRIPT })
|
||||
const result = await execute(ctx, { script: SCRIPT, meta: META })
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
|
||||
expect(engine.requests.length).toBe(0)
|
||||
@@ -157,7 +158,7 @@ describe('dsh-tool-workflow', () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const result = await execute(ctx, { script: SCRIPT }, { agent: parent, signal: controller.signal })
|
||||
const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(engine.cancels).toContain('parent step aborted')
|
||||
expect(engine.disposed).toBe(1)
|
||||
@@ -165,7 +166,7 @@ describe('dsh-tool-workflow', () => {
|
||||
|
||||
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 })
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { 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
|
||||
@@ -193,22 +194,22 @@ describe('dsh-tool-workflow', () => {
|
||||
expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false)
|
||||
})
|
||||
|
||||
it('presents a generic pending card titled by the sniffed meta name, with the script as rawInput', async () => {
|
||||
it('presents a generic pending card titled by the meta name, with the script as rawInput', async () => {
|
||||
const { ctx } = await setup()
|
||||
const tool = ctx.tools.get('workflow')!
|
||||
const view = tool.presentCall!({ script: SCRIPT })
|
||||
const view = tool.presentCall!({ script: SCRIPT, meta: META })
|
||||
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' })
|
||||
expect(tool.presentResult!({ script: SCRIPT, meta: META }, { content: [], isError: false })).toEqual({ card: 'generic' })
|
||||
// defineTool soft-validates presentation args: a malformed logged shape
|
||||
// falls back to undefined instead of throwing mid-replay.
|
||||
// (wrong fields entirely, or a call missing its meta) falls back to
|
||||
// undefined instead of throwing mid-replay.
|
||||
expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
|
||||
expect(tool.presentCall!({ script: SCRIPT })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default)', () => {
|
||||
@@ -239,7 +240,8 @@ describe('dsh-tool-workflow', () => {
|
||||
const parent = { id: AgentId('caller'), options: {} } as unknown as Agent
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, {
|
||||
script: "export const meta = { name: 'stuck', description: 'parks forever' }\nawait new Promise(() => {})\nreturn 1",
|
||||
script: 'await new Promise(() => {})\nreturn 1',
|
||||
meta: { name: 'stuck', description: 'parks forever' },
|
||||
}, { agent: parent, signal: controller.signal })
|
||||
// Give the run a beat to start (past its synchronous slice), then abort.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
|
||||
Reference in New Issue
Block a user