diff --git a/scripts/build.ts b/scripts/build.ts index d767ef1..4be4e27 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -39,6 +39,7 @@ const fullExperimentalFeatures = [ "UNATTENDED_RETRY", "VERIFICATION_AGENT", "VOICE_MODE", + "WORKFLOW_SCRIPTS", ] as const; function runCommand(cmd: string[]): string | null { diff --git a/src/__tests__/workflowRuntime.test.ts b/src/__tests__/workflowRuntime.test.ts new file mode 100644 index 0000000..d236895 --- /dev/null +++ b/src/__tests__/workflowRuntime.test.ts @@ -0,0 +1,261 @@ +/** + * item 25A: 最小 workflow 执行 runtime 单测 (CC 2.1.229) + */ +import { describe, expect, it } from "bun:test"; +import { + evaluateScript, + extractMeta, + isWorkflowRuntimeEnabled, +} from "../tools/WorkflowTool/runtime.js"; + +// ─── isWorkflowRuntimeEnabled ─── + +describe("isWorkflowRuntimeEnabled", () => { + const orig = process.env.FUSION_WORKFLOW_RUNTIME_ENABLED; + + it("未设 → false (default off)", () => { + delete process.env.FUSION_WORKFLOW_RUNTIME_ENABLED; + expect(isWorkflowRuntimeEnabled()).toBe(false); + }); + + it("0 → false", () => { + process.env.FUSION_WORKFLOW_RUNTIME_ENABLED = "0"; + expect(isWorkflowRuntimeEnabled()).toBe(false); + }); + + it("1 → true", () => { + process.env.FUSION_WORKFLOW_RUNTIME_ENABLED = "1"; + expect(isWorkflowRuntimeEnabled()).toBe(true); + }); + + it("其他值 → false", () => { + process.env.FUSION_WORKFLOW_RUNTIME_ENABLED = "true"; + expect(isWorkflowRuntimeEnabled()).toBe(false); + }); + + if (orig === undefined) { + delete process.env.FUSION_WORKFLOW_RUNTIME_ENABLED; + } else { + process.env.FUSION_WORKFLOW_RUNTIME_ENABLED = orig; + } +}); + +// ─── extractMeta ─── + +describe("extractMeta", () => { + it("基本 meta + body", () => { + const src = `export const meta = { name: "t", description: "d", phases: [] };\nconst x = 1;`; + const { meta, body } = extractMeta(src); + expect(meta.name).toBe("t"); + expect(meta.description).toBe("d"); + expect(body).toBe("const x = 1;"); + }); + + it("嵌套花括号对象", () => { + const src = `export const meta = { name: "t", description: "d", phases: [{ title: "a", detail: "b" }] };\nreturn 42;`; + const { meta, body } = extractMeta(src); + expect(meta.name).toBe("t"); + expect((meta.phases as Array<{ title: string }>)[0].title).toBe("a"); + expect(body).toBe("return 42;"); + }); + + it("字符串内花括号不破坏平衡", () => { + const src = `export const meta = { name: "t", description: "has } brace" };\nlog("done");`; + const { meta, body } = extractMeta(src); + expect(meta.description).toBe("has } brace"); + expect(body).toBe('log("done");'); + }); + + it("缺 meta → 抛错 (fail visibly)", () => { + expect(() => extractMeta("const x = 1;")).toThrow(); + }); + + it("name 非字符串 → 抛错", () => { + const src = `export const meta = { name: 5, description: "d" };\nreturn 1;`; + expect(() => extractMeta(src)).toThrow(); + }); + + it("尾随无分号也工作", () => { + const src = `export const meta = { name: "t", description: "d" }\nreturn 1;`; + const { meta, body } = extractMeta(src); + expect(meta.name).toBe("t"); + expect(body).toBe("return 1;"); + }); +}); + +// ─── evaluateScript: primitives (stub agent, 真 parallel/pipeline/log/phase/budget/workflow) ─── + +function makeStubs(overrides?: { + agent?: (prompt: string) => Promise; +}) { + const logs: string[] = []; + const phases: string[] = []; + const agent = + overrides?.agent ?? (async (prompt: string) => `agent:${prompt}`); + return { + logs, + phases, + primitives: { + args: undefined, + agent, + parallel: async (thunks: Array<() => Promise>) => { + const cap = Math.max(1, Math.min(16, 2)); + let next = 0; + const results: Array = new Array(thunks.length).fill(null); + const worker = async () => { + while (true) { + const idx = next++; + if (idx >= thunks.length) return; + try { + results[idx] = await thunks[idx](); + } catch { + results[idx] = null; + } + } + }; + await Promise.all( + Array.from({ length: Math.min(cap, thunks.length) }, worker), + ); + return results; + }, + pipeline: async ( + items: unknown[], + ...stages: Array< + (prev: unknown, orig: unknown, i: number) => Promise + > + ) => { + const out: Array = []; + for (const [i, orig] of items.entries()) { + let prev: unknown = null; + try { + for (const stage of stages) { + prev = await stage(prev, orig, i); + } + out.push(prev); + } catch { + out.push(null); + } + } + return out; + }, + phase: (title: string) => { + phases.push(title); + }, + log: (message: string) => { + logs.push(message); + }, + workflow: async () => { + throw new Error( + "nested workflow() not supported in minimal runtime (v1)", + ); + }, + budget: { + total: null, + spent: () => 0, + remaining: () => Number.POSITIVE_INFINITY, + }, + }, + }; +} + +describe("evaluateScript", () => { + it("顶层 return 取结果", async () => { + const src = `export const meta = { name: "t", description: "d" };\nreturn 42;`; + const { primitives } = makeStubs(); + const result = await evaluateScript(src, primitives); + expect(result).toBe(42); + }); + + it("顶层 await agent() 调原语", async () => { + const src = `export const meta = { name: "t", description: "d" };\nconst r = await agent("hello");\nreturn r;`; + const { primitives } = makeStubs(); + const result = await evaluateScript(src, primitives); + expect(result).toBe("agent:hello"); + }); + + it("parallel() 并发返回数组", async () => { + const src = `export const meta = { name: "t", description: "d" };\nconst r = await parallel([() => Promise.resolve("a"), () => Promise.resolve("b")]);\nreturn r;`; + const { primitives } = makeStubs(); + const result = (await evaluateScript(src, primitives)) as string[]; + expect(result).toContain("a"); + expect(result).toContain("b"); + expect(result.length).toBe(2); + }); + + it("parallel() 某 thunk throw → 该项 null 不 reject 整体", async () => { + const src = `export const meta = { name: "t", description: "d" };\nconst r = await parallel([() => Promise.resolve("ok"), () => Promise.reject(new Error("x"))]);\nreturn r;`; + const { primitives } = makeStubs(); + const result = (await evaluateScript(src, primitives)) as (string | null)[]; + expect(result).toContain("ok"); + expect(result).toContain(null); + }); + + it("pipeline() 每 item 独立穿 stage", async () => { + // stage1 用 original 加 10, stage2 乘 2 → [22, 24] + const src = `export const meta = { name: "t", description: "d" };\nconst r = await pipeline([1, 2], async (p, orig) => orig + 10, async (p) => p * 2);\nreturn r;`; + const { primitives } = makeStubs(); + const result = (await evaluateScript(src, primitives)) as number[]; + expect(result).toContain(22); + expect(result).toContain(24); + }); + + it("log() 记录", async () => { + const src = `export const meta = { name: "t", description: "d" };\nlog("hello");\nreturn "done";`; + const { logs, primitives } = makeStubs(); + await evaluateScript(src, primitives); + expect(logs).toContain("hello"); + }); + + it("phase() 记录", async () => { + const src = `export const meta = { name: "t", description: "d" };\nphase("Scan");\nreturn "done";`; + const { phases, primitives } = makeStubs(); + await evaluateScript(src, primitives); + expect(phases).toContain("Scan"); + }); + + it("budget stub 可访问", async () => { + const src = `export const meta = { name: "t", description: "d" };\nreturn budget.remaining();`; + const { primitives } = makeStubs(); + const result = await evaluateScript(src, primitives); + expect(result).toBe(Infinity); + }); + + it("workflow() 抛 NotImplemented", async () => { + const src = `export const meta = { name: "t", description: "d" };\nreturn await workflow("x");`; + const { primitives } = makeStubs(); + await expect(evaluateScript(src, primitives)).rejects.toThrow( + /not supported in minimal runtime/, + ); + }); + + it("args 透传", async () => { + const src = `export const meta = { name: "t", description: "d" };\nreturn args;`; + const { primitives } = makeStubs(); + primitives.args = { key: "val" }; + const result = await evaluateScript(src, primitives); + expect(result).toEqual({ key: "val" }); + }); + + it("TS 类型注解剥除", async () => { + const src = `export const meta = { name: "t", description: "d" };\nconst x: number = 5;\nreturn x;`; + const { primitives } = makeStubs(); + const result = await evaluateScript(src, primitives); + expect(result).toBe(5); + }); + + it("残留 export 语句 → 抛错", async () => { + const src = `export const meta = { name: "t", description: "d" };\nexport const extra = 1;\nreturn 1;`; + const { primitives } = makeStubs(); + await expect(evaluateScript(src, primitives)).rejects.toThrow( + /additional export/, + ); + }); + + it("缺 meta → 抛错", async () => { + const src = `return 1;`; + const { primitives } = makeStubs(); + await expect(evaluateScript(src, primitives)).rejects.toThrow( + /export const meta/, + ); + }); +}); diff --git a/src/tools/WorkflowTool/WorkflowTool.ts b/src/tools/WorkflowTool/WorkflowTool.ts index a951b30..5059219 100644 --- a/src/tools/WorkflowTool/WorkflowTool.ts +++ b/src/tools/WorkflowTool/WorkflowTool.ts @@ -1,11 +1,14 @@ +import { feature } from "bun:bundle"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { z } from "zod/v4"; import { buildTool } from "../../Tool.js"; import { logForDebugging } from "../../utils/debug.js"; import { lazySchema } from "../../utils/lazySchema.js"; +import { emitPerfettoInstant } from "../../utils/telemetry/perfettoTracing.js"; import { WORKFLOW_TOOL_NAME } from "./constants.js"; import { DESCRIPTION, getPrompt } from "./prompt.js"; +import { executeWorkflow, isWorkflowRuntimeEnabled } from "./runtime.js"; import { parseYamlWorkflow } from "./yamlLoader.js"; const inputSchema = lazySchema(() => @@ -143,7 +146,7 @@ export const WorkflowTool = buildTool({ get outputSchema(): OutputSchema { return outputSchema(); }, - async execute(input, _context, _canUseTool?, _parentMessage?, _onProgress?) { + async execute(input, context, canUseTool?, _parentMessage?, onProgress?) { // log: fixed execute signature const runId = `wf_${randomUUID().slice(0, 8)}`; logForDebugging( @@ -174,8 +177,54 @@ export const WorkflowTool = buildTool({ }; } + // 双门禁: feature("WORKFLOW_SCRIPTS") (编译期, build:dev:full) AND + // FUSION_WORKFLOW_RUNTIME_ENABLED=1 (运行期)。两层都满足才跑 runtime; + // 否则 byte-identical 验证桩 (旧行为)。 + if (feature("WORKFLOW_SCRIPTS") && isWorkflowRuntimeEnabled()) { + activeRuns.set(runId, { status: "running", startTime: Date.now() }); + emitPerfettoInstant("workflow_run_started", "workflow", { runId }); + logForDebugging( + `[Workflow] runtime enabled: executing script for run ${runId}`, + ); + try { + const result = await executeWorkflow({ + scriptSource, + args: input.args, + runId, + toolUseContext: context, + canUseTool: + canUseTool ?? ((async () => ({ behavior: "allow" })) as never), + querySource: context?.options?.querySource ?? ("tool" as never), + abortController: context?.abortController ?? new AbortController(), + onProgress: onProgress as never, + }); + activeRuns.set(runId, { status: "completed", startTime: Date.now() }); + emitPerfettoInstant("workflow_run_completed", "workflow", { runId }); + return { + data: { + runId, + status: "completed" as const, + message: `Workflow run completed. Result: ${safeStringify(result)}`, + }, + }; + } catch (err) { + activeRuns.set(runId, { status: "error", startTime: Date.now() }); + emitPerfettoInstant("workflow_run_error", "workflow", { + runId, + error: String((err as Error).message ?? err), + }); + return { + data: { + runId, + status: "error" as const, + message: `Workflow failed: ${(err as Error).message ?? err}`, + }, + }; + } + } + activeRuns.set(runId, { status: "started", startTime: Date.now() }); - logForDebugging(`[Workflow] run ${runId} started`); + logForDebugging(`[Workflow] run ${runId} started (stub mode)`); try { const scriptName = input.name || input.scriptPath || "inline"; @@ -213,3 +262,12 @@ export const WorkflowTool = buildTool({ }; }, }); + +function safeStringify(value: unknown): string { + try { + const s = typeof value === "string" ? value : JSON.stringify(value); + return s ?? "undefined"; + } catch { + return String(value); + } +} diff --git a/src/tools/WorkflowTool/runtime.ts b/src/tools/WorkflowTool/runtime.ts new file mode 100644 index 0000000..c0284ed --- /dev/null +++ b/src/tools/WorkflowTool/runtime.ts @@ -0,0 +1,502 @@ +// item 25A (CC 2.1.229): 最小 workflow 执行 runtime。 +// 双门禁: feature("WORKFLOW_SCRIPTS") (编译期) AND FUSION_WORKFLOW_RUNTIME_ENABLED=1 (运行期)。 +// 禁用时 WorkflowTool.execute() 行为 byte-identical 旧桩。 +// agent() 用 runAgent drain → getAssistantMessageText 取最终文本, transcriptSubdir="workflows/"。 +// v1 DEFERRED: schema (agent 返回纯文本), 嵌套 workflow() (抛 NotImplemented), budget (stub)。 + +import type { QuerySource } from "../../constants/querySource.js"; +import type { CanUseToolFn } from "../../hooks/useCanUseTool.js"; +import type { ToolUseContext } from "../../Tool.js"; +import type { AssistantMessage } from "../../types/message.js"; +import { logForDebugging } from "../../utils/debug.js"; +import { + createUserMessage, + getAssistantMessageText, +} from "../../utils/messages.js"; +import type { ModelAlias } from "../../utils/model/aliases.js"; +import { emitPerfettoInstant } from "../../utils/telemetry/perfettoTracing.js"; +import type { AgentDefinition } from "../AgentTool/loadAgentsDir.js"; +import { runAgent } from "../AgentTool/runAgent.js"; + +// ─── runtime env gate ─── + +// 运行期门禁。编译期门禁 feature("WORKFLOW_SCRIPTS") 在 WorkflowTool.execute() 内。 +// 两层都满足才执行 runtime; 否则 byte-identical 验证桩。 +export function isWorkflowRuntimeEnabled(): boolean { + return process.env.FUSION_WORKFLOW_RUNTIME_ENABLED === "1"; +} + +// ─── 并发上限 (防 runaway 成本, 对齐 prompt.ts 文档 min(16, cpu-2)) ─── + +function concurrencyCap(): number { + const cpus = + typeof navigator !== "undefined" && navigator.hardwareConcurrency + ? navigator.hardwareConcurrency + : 4; + return Math.max(1, Math.min(16, cpus - 2)); +} + +// ─── 并发池: 限并发 cap, 每 thunk throw→该项 null (不 reject 整体) ─── + +async function runWithConcurrency( + thunks: Array<() => Promise>, + cap: number, +): Promise> { + const results: Array = new Array(thunks.length).fill(null); + let next = 0; + const worker = async () => { + while (true) { + const idx = next++; + if (idx >= thunks.length) return; + try { + results[idx] = await thunks[idx](); + } catch (err) { + // Rule 12: 单项失败不连累整体, 该项记 null。 + logForDebugging( + `[Workflow] parallel item ${idx} failed: ${(err as Error).message}`, + ); + results[idx] = null; + } + } + }; + const workers = Array.from({ length: Math.min(cap, thunks.length) }, worker); + await Promise.all(workers); + return results; +} + +// ─── meta 抽取 (花括号平衡计数器, 支持嵌套对象) ─── + +// 返回 { meta, body } — meta 为解析后的对象, body 为剥除 const meta = ...; 后的余串。 +// marker 可为 "export const meta" 或 "const __meta__" (转译后)。 +// 失败抛 Error (fail visibly)。 +function extractMetaFrom( + source: string, + marker: string, +): { + meta: { name: string; description: string; phases?: unknown }; + body: string; +} { + const start = source.indexOf(marker); + if (start === -1) { + throw new Error( + "Workflow script must begin with: export const meta = { name, description, phases }", + ); + } + // 定位 "=" 后首个 "{" 开始花括号平衡。 + const eq = source.indexOf("=", start); + if (eq === -1) throw new Error("meta declaration missing '='"); + let i = eq + 1; + while (i < source.length && source[i] !== "{") i++; + if (i >= source.length) { + throw new Error("meta declaration missing object literal"); + } + const objStart = i; + let depth = 0; + let inStr: string | null = null; + for (; i < source.length; i++) { + const ch = source[i]; + if (inStr) { + if (ch === "\\") { + i++; + continue; + } + if (ch === inStr) inStr = null; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { + inStr = ch; + continue; + } + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + i++; + break; + } + } + } + if (depth !== 0) throw new Error("meta declaration: unbalanced braces"); + const objStr = source.slice(objStart, i); + let meta: { name: string; description: string; phases?: unknown }; + try { + // new Function 求对象字面量 (eval 受双门禁防护)。 + meta = new Function(`return (${objStr})`)() as typeof meta; + } catch (err) { + throw new Error( + `meta declaration: invalid object literal: ${(err as Error).message}`, + ); + } + if ( + !meta || + typeof meta.name !== "string" || + typeof meta.description !== "string" + ) { + throw new Error("meta declaration: name and description must be strings"); + } + // 剥 marker...= ...; 语句 → 余 body。吞掉尾随 ";"。 + let stmtEnd = i; + if (source[stmtEnd] === ";") stmtEnd++; + const body = (source.slice(0, start) + source.slice(stmtEnd)).trim(); + return { meta, body }; +} + +// 对外导出: 在原始 (未转译) 源上抽 meta (marker = "export const meta")。 +// 测试用。生产 evaluateScript 在转译后抽。 +export function extractMeta(source: string): { + meta: { name: string; description: string; phases?: unknown }; + body: string; +} { + return extractMetaFrom(source, "export const meta"); +} + +// ─── script eval (transpile → 抽 meta → 剥 export → async IIFE 封装) ─── + +// 注意: new Function eval model-authored 脚本 (全局作用域), 理论可触危险全局。 +// 双门禁防护 (编译+运行), 对齐 CC 自身 Workflow 工具风险接受。完整沙箱超 v1 范围。 +// transpile 失败 → 跳转译仅支持纯 JS (报 .ts 源 error 时由 catch 显式抛)。 +type PrimitiveArgs = { + args: unknown; + agent: AgentPrimitive; + parallel: ParallelPrimitive; + pipeline: PipelinePrimitive; + phase: (title: string) => void; + log: (message: string) => void; + workflow: WorkflowPrimitive; + budget: BudgetStub; +}; + +export async function evaluateScript( + scriptSource: string, + primitives: PrimitiveArgs, +): Promise { + // 转译流水线: 脚本含顶层 await/return + export const meta (非合法 ESM), + // 直接 Bun.Transpiler(loader:ts) 会抛 (module 不许顶层 return/export)。 + // 变换: export const meta → const __meta__ (保留对象, 去除 export 关键字), + // 包 async function _w(){...} (使顶层 await/return 合法), 转译剥 TS 类型, + // 拆包 → 余 body。 + let body: string; + const metaMarker = "export const meta"; + const hasMetaMarker = scriptSource.includes(metaMarker); + try { + const noExport = hasMetaMarker + ? scriptSource.replace(metaMarker, "const __meta__") + : scriptSource; + const wrapped = `async function _w() {\n${noExport}\n}`; + const transpiled = new Bun.Transpiler({ loader: "ts" }).transformSync( + wrapped, + ); + // 拆包: 去首行 "async function _w() {" 与尾 "}"。 + const inner = transpiled + .replace(/^\s*async function _w\(\)\s*\{/, "") + .replace(/\}\s*$/, ""); + // 抽 meta (转译后 marker = "const __meta__") 并剥除 → body。 + if (!hasMetaMarker) { + throw new Error( + "Workflow script must begin with: export const meta = { name, description, phases }", + ); + } + const extracted = extractMetaFrom(inner, "const __meta__"); + body = extracted.body; + } catch (err) { + // 转译失败: 退回原串抽 meta (仅支持纯 JS 源)。 + if (err instanceof Error && err.message.includes("export const meta")) { + throw err; + } + logForDebugging( + `[Workflow] transpile failed, using raw source: ${(err as Error).message}`, + ); + const extracted = extractMeta(scriptSource); + body = extracted.body; + } + + // 校验无残留语句级 export (new Function 会抛)。 + if (/\bexport\b\s/.test(body)) { + throw new Error( + "Workflow script body must not contain additional export statements", + ); + } + + // 封装: agent/parallel/... 作函数参数(在作用域内), 顶层 await+return 在 async IIFE 内合法。 + const fn = new Function( + "args", + "agent", + "parallel", + "pipeline", + "phase", + "log", + "workflow", + "budget", + `return (async () => {\n${body}\n})();`, + ) as ( + args: unknown, + agent: AgentPrimitive, + parallel: ParallelPrimitive, + pipeline: PipelinePrimitive, + phase: (title: string) => void, + log: (message: string) => void, + workflow: WorkflowPrimitive, + budget: BudgetStub, + ) => Promise; + + const result = await fn( + primitives.args, + primitives.agent, + primitives.parallel, + primitives.pipeline, + primitives.phase, + primitives.log, + primitives.workflow, + primitives.budget, + ); + return result; +} + +// ─── agent() primitive ─── + +// agent(prompt, opts?) → string|null (最终 assistant 文本)。subagent 死/无文本 → null。 +// schema DEFERRED: 传 opts.schema 记 warn 忽略, 返回纯文本。 +type AgentOpts = { + label?: string; + phase?: string; + model?: ModelAlias; + effort?: string; + agentType?: string; + schema?: unknown; +}; +type AgentPrimitive = ( + prompt: string, + opts?: AgentOpts, +) => Promise; + +type AgentCtx = { + runId: string; + toolUseContext: ToolUseContext; + canUseTool: CanUseToolFn; + querySource: QuerySource; + abortController: AbortController; + onProgress?: (event: { type: string; [k: string]: unknown }) => void; +}; + +function createAgentPrimitive(ctx: AgentCtx): AgentPrimitive { + let agentCounter = 0; + return async (prompt, opts) => { + if (ctx.abortController.signal.aborted) { + throw new Error("Workflow aborted"); + } + if (opts?.schema) { + logForDebugging( + `[Workflow] agent() schema option not supported in v1, ignoring`, + ); + } + const agentType = opts?.agentType ?? "general-purpose"; + const agentDefinition: AgentDefinition | undefined = + ctx.toolUseContext.options.agentDefinitions.activeAgents.find( + (a) => a.agentType === agentType, + ); + if (!agentDefinition) { + logForDebugging( + `[Workflow] agent type "${agentType}" not found, defaulting to general-purpose`, + ); + const fallback = + ctx.toolUseContext.options.agentDefinitions.activeAgents.find( + (a) => a.agentType === "general-purpose", + ); + if (!fallback) { + throw new Error(`No agent available for type "${agentType}"`); + } + return runOneAgent(ctx, fallback, prompt, opts, ++agentCounter); + } + return runOneAgent(ctx, agentDefinition, prompt, opts, ++agentCounter); + }; +} + +async function runOneAgent( + ctx: AgentCtx, + agentDefinition: AgentDefinition, + prompt: string, + opts: AgentOpts | undefined, + idx: number, +): Promise { + const promptMessages = [createUserMessage({ content: prompt })]; + const transcriptSubdir = `workflows/${ctx.runId}`; + const label = opts?.label ?? `${agentDefinition.agentType}-${idx}`; + ctx.onProgress?.({ type: "agent_start", label, phase: opts?.phase }); + emitPerfettoInstant("workflow_agent_start", "workflow", { + runId: ctx.runId, + agentType: agentDefinition.agentType, + label, + }); + + const collected: AssistantMessage[] = []; + const generator = runAgent({ + agentDefinition, + promptMessages, + toolUseContext: ctx.toolUseContext, + canUseTool: ctx.canUseTool, + isAsync: false, + querySource: ctx.querySource, + availableTools: ctx.toolUseContext.options.tools, + override: { abortController: ctx.abortController }, + model: opts?.model, + transcriptSubdir, + }); + + for await (const message of generator) { + if (ctx.abortController.signal.aborted) { + throw new Error("Workflow aborted"); + } + if (message.type === "assistant") { + collected.push(message as AssistantMessage); + } + } + + // 取最后一条 assistant 消息的文本 (finalizeAgentTool 太重, 直接取)。 + const last = collected[collected.length - 1]; + const text = last ? getAssistantMessageText(last) : null; + ctx.onProgress?.({ + type: "agent_end", + label, + phase: opts?.phase, + hasText: text != null, + }); + emitPerfettoInstant("workflow_agent_end", "workflow", { + runId: ctx.runId, + label, + hasText: text != null, + }); + return text; +} + +// ─── parallel / pipeline primitives ─── + +type ParallelPrimitive = ( + thunks: Array<() => Promise>, +) => Promise>; + +function createParallelPrimitive(): ParallelPrimitive { + return async (thunks) => runWithConcurrency(thunks, concurrencyCap()); +} + +type Stage = ( + prev: unknown, + original: unknown, + index: number, +) => Promise; +type PipelinePrimitive = ( + items: unknown[], + ...stages: Array +) => Promise>; + +function createPipelinePrimitive(): PipelinePrimitive { + return async (items, ...stages) => { + // 每 item 独立穿所有 stage (无 barrier)。stage throw → 该 item drop null 跳余 stage。 + const thunks = items.map((original, index) => async () => { + let prev: unknown = null; + for (const stage of stages) { + try { + prev = await stage(prev, original, index); + } catch (err) { + logForDebugging( + `[Workflow] pipeline item ${index} stage failed: ${(err as Error).message}`, + ); + return null; + } + } + return prev; + }); + return runWithConcurrency(thunks, concurrencyCap()); + }; +} + +// ─── phase / log / workflow / budget primitives ─── + +type WorkflowPrimitive = ( + nameOrRef: string, + args?: unknown, +) => Promise; +type BudgetStub = { + total: number | null; + spent: () => number; + remaining: () => number; +}; + +function createMiscPrimitives(ctx: AgentCtx): { + phase: (title: string) => void; + log: (message: string) => void; + workflow: WorkflowPrimitive; + budget: BudgetStub; +} { + let currentPhase = ""; + return { + phase(title) { + currentPhase = title; + emitPerfettoInstant("workflow_phase", "workflow", { + runId: ctx.runId, + phase: title, + }); + }, + log(message) { + ctx.onProgress?.({ type: "log", message, phase: currentPhase }); + logForDebugging(`[Workflow ${ctx.runId}] ${message}`); + }, + // 嵌套 workflow DEFERRED v1 (spec 限一层)。 + async workflow(_nameOrRef, _args) { + throw new Error( + "nested workflow() not supported in minimal runtime (v1)", + ); + }, + // budget DEFERRED v1: stub, 无真实 token 预算强制。 + budget: { + total: null, + spent: () => 0, + remaining: () => Number.POSITIVE_INFINITY, + }, + }; +} + +// ─── executeWorkflow entry ─── + +type ExecuteWorkflowParams = { + scriptSource: string; + args: unknown; + runId: string; + toolUseContext: ToolUseContext; + canUseTool: CanUseToolFn; + querySource: QuerySource; + abortController: AbortController; + onProgress?: (event: { type: string; [k: string]: unknown }) => void; +}; + +// 抛错由调用方 (WorkflowTool.execute) catch → activeRuns error + 返回 error。 +// 成功返回脚本 return 的结果对象 (可 undefined)。 +export async function executeWorkflow( + params: ExecuteWorkflowParams, +): Promise { + const ctx: AgentCtx = { + runId: params.runId, + toolUseContext: params.toolUseContext, + canUseTool: params.canUseTool, + querySource: params.querySource, + abortController: params.abortController, + onProgress: params.onProgress, + }; + const agent = createAgentPrimitive(ctx); + const parallel = createParallelPrimitive(); + const pipeline = createPipelinePrimitive(); + const { phase, log, workflow, budget } = createMiscPrimitives(ctx); + + const primitives: PrimitiveArgs = { + args: params.args, + agent, + parallel, + pipeline, + phase, + log, + workflow, + budget, + }; + + logForDebugging(`[Workflow] run ${params.runId} executing runtime`); + return evaluateScript(params.scriptSource, primitives); +}