Skip to content

Commit 09c436d

Browse files
feat(workflow): add self-contained WorkflowTool descriptor (Phase 3)
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 164ffc2 commit 09c436d

5 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { expect, test } from 'bun:test'
2+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { createWorkflowTool } from '../tool/WorkflowTool.js'
6+
import { createHostHandle, type WorkflowPorts } from '../ports.js'
7+
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
8+
9+
function mockPorts(
10+
runsDir: string,
11+
results: Map<string, AgentRunResult>,
12+
): {
13+
ports: WorkflowPorts
14+
events: ProgressEvent[]
15+
runStatus: Map<string, string>
16+
} {
17+
const events: ProgressEvent[] = []
18+
const runStatus = new Map<string, string>()
19+
const ports: WorkflowPorts = {
20+
agentRunner: {
21+
runAgentToResult: async (p: AgentRunParams) =>
22+
results.get(p.prompt) ?? { kind: 'dead' },
23+
},
24+
progressEmitter: { emit: e => void events.push(e) },
25+
taskRegistrar: {
26+
register: () => ({
27+
runId: 'run-x',
28+
signal: new AbortController().signal,
29+
}),
30+
complete: id => void runStatus.set(id, 'completed'),
31+
fail: id => void runStatus.set(id, 'failed'),
32+
kill: id => void runStatus.set(id, 'killed'),
33+
pendingAction: () => null,
34+
},
35+
journalStore: {
36+
read: async () => [],
37+
append: async () => {},
38+
truncate: async () => {},
39+
},
40+
permissionGate: { isAborted: () => false },
41+
logger: { debug: () => {}, event: () => {} },
42+
hostFactory: () => ({
43+
handle: createHostHandle(null),
44+
cwd: runsDir,
45+
budgetTotal: null,
46+
}),
47+
}
48+
return { ports, events, runStatus }
49+
}
50+
51+
test('call 返回 launch 消息并在后台完成', async () => {
52+
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
53+
try {
54+
const { ports, runStatus } = mockPorts(
55+
dir,
56+
new Map([
57+
['compute', { kind: 'ok', output: '42', usage: { outputTokens: 1 } }],
58+
]),
59+
)
60+
const tool = createWorkflowTool(ports)
61+
const res = await tool.call(
62+
{ script: `return agent('compute')` },
63+
undefined,
64+
undefined,
65+
undefined,
66+
)
67+
expect(res.data.output).toContain('run_id: run-x')
68+
await new Promise(r => {
69+
setTimeout(r, 50)
70+
})
71+
expect(runStatus.get('run-x')).toBe('completed')
72+
} finally {
73+
await rm(dir, { recursive: true, force: true })
74+
}
75+
})
76+
77+
test('缺少 script/name/scriptPath → 返回错误(不进后台)', async () => {
78+
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
79+
try {
80+
const { ports, runStatus } = mockPorts(dir, new Map())
81+
const tool = createWorkflowTool(ports)
82+
const res = await tool.call({}, undefined, undefined, undefined)
83+
expect(res.data.output).toMatch(/^Error:/)
84+
expect(runStatus.size).toBe(0)
85+
} finally {
86+
await rm(dir, { recursive: true, force: true })
87+
}
88+
})
89+
90+
test('脚本语法错 → 返回校验错误(不进后台)', async () => {
91+
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
92+
try {
93+
const { ports, runStatus } = mockPorts(dir, new Map())
94+
const tool = createWorkflowTool(ports)
95+
const res = await tool.call(
96+
{ script: `return ((` },
97+
undefined,
98+
undefined,
99+
undefined,
100+
)
101+
expect(res.data.output).toMatch(/|Error/)
102+
expect(runStatus.size).toBe(0)
103+
} finally {
104+
await rm(dir, { recursive: true, force: true })
105+
}
106+
})
107+
108+
test('name 解析到 .claude/workflows/<name>.ts', async () => {
109+
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
110+
try {
111+
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
112+
await writeFile(
113+
join(dir, '.claude', 'workflows', 'release.ts'),
114+
`return agent('compute')`,
115+
)
116+
const { ports, runStatus } = mockPorts(
117+
dir,
118+
new Map([
119+
['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }],
120+
]),
121+
)
122+
const tool = createWorkflowTool(ports)
123+
const res = await tool.call(
124+
{ name: 'release' },
125+
undefined,
126+
undefined,
127+
undefined,
128+
)
129+
expect(res.data.output).toContain('run_id')
130+
await new Promise(r => {
131+
setTimeout(r, 50)
132+
})
133+
expect(runStatus.get('run-x')).toBe('completed')
134+
} finally {
135+
await rm(dir, { recursive: true, force: true })
136+
}
137+
})
138+
139+
test('renderToolUseMessage / mapToolResultToToolResultBlockParam', () => {
140+
const dir = '/tmp'
141+
const { ports } = mockPorts(dir, new Map())
142+
const tool = createWorkflowTool(ports)
143+
expect(tool.renderToolUseMessage({ name: 'release' })).toBe(
144+
'Workflow: release',
145+
)
146+
const block = tool.mapToolResultToToolResultBlockParam(
147+
{ output: 'hi' },
148+
'tu-1',
149+
)
150+
expect(block.tool_use_id).toBe('tu-1')
151+
expect(block.type).toBe('tool_result')
152+
expect(block.content[0]!.text).toBe('hi')
153+
})

packages/workflow-engine/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,9 @@ export * from './engine/context.js'
1515
export * from './engine/hooks.js'
1616
export * from './engine/runWorkflow.js'
1717
export * from './progress/events.js'
18+
export {
19+
createWorkflowTool,
20+
type WorkflowToolDescriptor,
21+
} from './tool/WorkflowTool.js'
22+
export { workflowInputSchema } from './tool/schema.js'
23+
export { WORKFLOW_TOOL_NAME } from './tool/constants.js'
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { join } from 'node:path'
3+
import { z } from 'zod/v4'
4+
import { WORKFLOW_DIR_NAME, WORKFLOW_TOOL_NAME } from '../constants.js'
5+
import { resolveNamedWorkflow } from '../engine/namedWorkflows.js'
6+
import { runWorkflow } from '../engine/runWorkflow.js'
7+
import { parseScript } from '../engine/script.js'
8+
import type { WorkflowPorts } from '../ports.js'
9+
import type { WorkflowInput, WorkflowRunResult } from '../types.js'
10+
import { workflowInputSchema } from './schema.js'
11+
12+
/** 自包含工具描述符(核心 wiring 用 buildTool 包装它)。零核心层依赖。 */
13+
export type WorkflowToolDescriptor = {
14+
name: string
15+
inputSchema: z.ZodType<WorkflowInput>
16+
isEnabled: () => boolean
17+
isReadOnly: (input: WorkflowInput) => boolean
18+
description: () => Promise<string>
19+
prompt: () => Promise<string>
20+
renderToolUseMessage: (input: Partial<WorkflowInput>) => string
21+
call: (
22+
input: WorkflowInput,
23+
context: unknown,
24+
canUseTool: unknown,
25+
parentMessage: unknown,
26+
onProgress?: unknown,
27+
) => Promise<{ data: { output: string } }>
28+
mapToolResultToToolResultBlockParam: (
29+
data: { output: string },
30+
toolUseId: string,
31+
) => {
32+
tool_use_id: string
33+
type: 'tool_result'
34+
content: Array<{ type: 'text'; text: string }>
35+
}
36+
}
37+
38+
const WORKFLOW_TOOL_PROMPT = `Use the Workflow tool to execute a workflow script that orchestrates multiple subagents deterministically. The script runs in the background; you receive a run_id immediately and are notified on completion.
39+
40+
Provide the script inline via "script", or reference a named workflow via "name" (resolved from .claude/workflows/), or an existing file via "scriptPath". Pass "args" as a real JSON value (object/array/string), not a stringified string.
41+
42+
Use "resumeFromRunId" to resume a prior run — completed agent() calls replay from the journal instantly.`
43+
44+
export function createWorkflowTool(
45+
ports: WorkflowPorts,
46+
): WorkflowToolDescriptor {
47+
return {
48+
name: WORKFLOW_TOOL_NAME,
49+
inputSchema: workflowInputSchema as unknown as z.ZodType<WorkflowInput>,
50+
isEnabled: () => true,
51+
isReadOnly: () => false,
52+
53+
async description() {
54+
return '执行一个 workflow 脚本,编排多个子 agent 完成任务'
55+
},
56+
57+
async prompt() {
58+
return WORKFLOW_TOOL_PROMPT
59+
},
60+
61+
renderToolUseMessage(input) {
62+
if (input.resumeFromRunId)
63+
return `Workflow resume: ${input.resumeFromRunId}`
64+
const id =
65+
input.name ?? input.scriptPath ?? (input.script ? 'inline' : 'unknown')
66+
return `Workflow: ${id}`
67+
},
68+
69+
async call(input, context, canUseTool, parentMessage) {
70+
const host = ports.hostFactory({ context, canUseTool, parentMessage })
71+
72+
// 解析脚本源
73+
let script: string
74+
let workflowFile: string | undefined
75+
try {
76+
const resolved = await resolveScriptSource(input, host.cwd)
77+
script = resolved.script
78+
workflowFile = resolved.workflowFile
79+
} catch (e) {
80+
return { data: { output: `Error: ${(e as Error).message}` } }
81+
}
82+
83+
// 快速校验(meta + 语法),失败直接返错给模型,不进后台
84+
try {
85+
parseScript(script)
86+
} catch (e) {
87+
return {
88+
data: { output: `Error: 脚本校验失败:${(e as Error).message}` },
89+
}
90+
}
91+
92+
const workflowName = input.name ?? input.title ?? 'workflow'
93+
const { runId, signal } = ports.taskRegistrar.register(
94+
{
95+
workflowName,
96+
...(workflowFile ? { workflowFile } : {}),
97+
...(input.description ? { summary: input.description } : {}),
98+
...(host.toolUseId ? { toolUseId: host.toolUseId } : {}),
99+
...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}),
100+
},
101+
host.handle,
102+
)
103+
104+
// detached 执行
105+
void runWorkflow({
106+
script,
107+
...(input.args !== undefined ? { args: input.args } : {}),
108+
runId,
109+
workflowName,
110+
ports,
111+
host: host.handle,
112+
signal,
113+
cwd: host.cwd,
114+
budgetTotal: host.budgetTotal,
115+
...(input.resumeFromRunId ? { resume: true } : {}),
116+
})
117+
.then(result => onFinish(ports, result, runId))
118+
.catch(e => ports.taskRegistrar.fail(runId, (e as Error).message))
119+
120+
const scriptPath = workflowFile ?? `<inline run ${runId}>`
121+
return {
122+
data: {
123+
output: [
124+
'Workflow 已启动(后台执行)。',
125+
`run_id: ${runId}`,
126+
`workflow: ${workflowName}`,
127+
`script: ${scriptPath}`,
128+
'',
129+
'完成时会自动通知。用 /workflows 查看实时进度。',
130+
].join('\n'),
131+
},
132+
}
133+
},
134+
135+
mapToolResultToToolResultBlockParam(data, toolUseId) {
136+
return {
137+
tool_use_id: toolUseId,
138+
type: 'tool_result',
139+
content: [{ type: 'text', text: data.output }],
140+
}
141+
},
142+
}
143+
}
144+
145+
function onFinish(
146+
ports: WorkflowPorts,
147+
result: WorkflowRunResult,
148+
runId: string,
149+
): void {
150+
if (result.status === 'completed') {
151+
const summary =
152+
result.returnValue == null
153+
? '(no return value)'
154+
: formatValue(result.returnValue)
155+
ports.taskRegistrar.complete(runId, summary)
156+
} else if (result.status === 'failed') {
157+
ports.taskRegistrar.fail(runId, result.error ?? 'workflow failed')
158+
} else {
159+
ports.taskRegistrar.kill(runId)
160+
}
161+
}
162+
163+
function formatValue(v: unknown): string {
164+
if (typeof v === 'string') return v.slice(0, 500)
165+
try {
166+
return JSON.stringify(v).slice(0, 500)
167+
} catch {
168+
return String(v)
169+
}
170+
}
171+
172+
async function resolveScriptSource(
173+
input: WorkflowInput,
174+
cwd: string,
175+
): Promise<{ script: string; workflowFile?: string }> {
176+
if (input.script) return { script: input.script }
177+
if (input.scriptPath) {
178+
return {
179+
script: await readFile(input.scriptPath, 'utf-8'),
180+
workflowFile: input.scriptPath,
181+
}
182+
}
183+
if (input.name) {
184+
const found = await resolveNamedWorkflow(
185+
join(cwd, WORKFLOW_DIR_NAME),
186+
input.name,
187+
)
188+
if (!found) {
189+
throw new Error(
190+
`命名 workflow "${input.name}" 未找到(查找目录 ${WORKFLOW_DIR_NAME}/)`,
191+
)
192+
}
193+
return { script: found.content, workflowFile: found.path }
194+
}
195+
throw new Error('必须提供 script、name 或 scriptPath 之一')
196+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { WORKFLOW_TOOL_NAME } from '../constants.js'
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { z } from 'zod/v4'
2+
3+
/** Workflow 工具输入 schema。args 为任意 JSON 值(对象/数组/字符串等)。 */
4+
export const workflowInputSchema = z.object({
5+
script: z
6+
.string()
7+
.optional()
8+
.describe('自包含的 workflow 脚本源码(inline)'),
9+
name: z
10+
.string()
11+
.optional()
12+
.describe('命名 workflow,解析到 .claude/workflows/<name>.ts|js|mjs'),
13+
scriptPath: z.string().optional().describe('已有脚本文件的绝对路径'),
14+
args: z
15+
.unknown()
16+
.optional()
17+
.describe(
18+
'透传给脚本的 args 全局变量。传真实 JSON 值(对象/数组/字符串),不要传 JSON 字符串。',
19+
),
20+
resumeFromRunId: z
21+
.string()
22+
.optional()
23+
.describe('resume 指定 run,重放 journal'),
24+
description: z.string().optional().describe('本次调用的简短描述(3-5 词)'),
25+
title: z.string().optional().describe('进度查看器标题'),
26+
})
27+
28+
export type WorkflowInputSchema = typeof workflowInputSchema

0 commit comments

Comments
 (0)