|
| 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 | +} |
0 commit comments