From cfe4543d3954b62e43650824df0e3f488cb5938b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 13 May 2026 08:03:27 +0000 Subject: [PATCH] refactor!: remove deprecated Agent types, introduce Adapter-first API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGES: - Remove AgentFn, AgentFnResult, AgentBinding from workflow-protocol - Remove wrapAgentAsAdapter from workflow-util-agent - workflowAsAgent → workflowAdapter (old name kept as deprecated re-export) New APIs: - createTextAdapter(producer) — bridges text-producing functions to AdapterFn - TextProducerFn, TextAdapterResult types - workflowAdapter() — direct AdapterFn for child workflow delegation All agent packages (cursor, hermes, llm) now return AdapterFn directly, no wrapping needed. Bundle entries simplified accordingly. 小橘 🍊(NEKO Team) --- .../__tests__/init-workspace.test.ts | 2 +- .../src/commands/init/workspace.ts | 10 +- .../__tests__/cursor-agent.test.ts | 4 +- packages/workflow-agent-cursor/src/index.ts | 21 ++- .../__tests__/hermes-agent.test.ts | 2 +- packages/workflow-agent-hermes/src/index.ts | 18 +- .../__tests__/create-llm-adapter.test.ts | 47 ++++- packages/workflow-agent-llm/package.json | 6 +- .../src/create-llm-adapter.ts | 20 +-- packages/workflow-agent-llm/tsconfig.json | 2 +- packages/workflow-execute/src/index.ts | 3 + .../workflow-execute/src/workflow-adapter.ts | 165 ++++++++++++++++++ .../workflow-execute/src/workflow-as-agent.ts | 140 +-------------- packages/workflow-protocol/src/index.ts | 3 - packages/workflow-protocol/src/types.ts | 12 -- packages/workflow-runtime/src/index.ts | 3 - packages/workflow-runtime/src/types.ts | 3 - .../workflow-template-develop/bundle-entry.ts | 5 +- .../bundle-entry.ts | 14 +- .../src/create-text-adapter.ts | 51 ++++++ packages/workflow-util-agent/src/index.ts | 3 +- .../src/wrap-agent-as-adapter.ts | 37 ---- 22 files changed, 319 insertions(+), 252 deletions(-) create mode 100644 packages/workflow-execute/src/workflow-adapter.ts create mode 100644 packages/workflow-util-agent/src/create-text-adapter.ts delete mode 100644 packages/workflow-util-agent/src/wrap-agent-as-adapter.ts diff --git a/packages/cli-workflow/__tests__/init-workspace.test.ts b/packages/cli-workflow/__tests__/init-workspace.test.ts index 07527d8..f338dbd 100644 --- a/packages/cli-workflow/__tests__/init-workspace.test.ts +++ b/packages/cli-workflow/__tests__/init-workspace.test.ts @@ -91,7 +91,7 @@ describe("init workspace", () => { "RoleDefinition", "WorkflowDefinition", "ModeratorTable", - "AgentFn", + "AdapterFn", "ExtractFn", "RoleMeta", ]) { diff --git a/packages/cli-workflow/src/commands/init/workspace.ts b/packages/cli-workflow/src/commands/init/workspace.ts index 1278abc..580d26d 100644 --- a/packages/cli-workflow/src/commands/init/workspace.ts +++ b/packages/cli-workflow/src/commands/init/workspace.ts @@ -90,7 +90,7 @@ function agentsMd(): string { |------|----------------|------| | **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 | | **Template** | \`templates//\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent | -| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) | +| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) | Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。 @@ -100,10 +100,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下 - **RoleDefinition**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。 - **WorkflowDefinition**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。 - **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。 -- **AgentFn**:\`(ctx: AgentContext) => Promise\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。 -- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。 +- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。 +- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。 -引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。 +引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。 ## 3. 开发流程 @@ -111,7 +111,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下 2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。 3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。 4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。 -5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。 +5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。 6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。 ## 4. 编码规范 diff --git a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts index b535139..b2f6676 100644 --- a/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts +++ b/packages/workflow-agent-cursor/__tests__/cursor-agent.test.ts @@ -79,7 +79,7 @@ describe("validateCursorAgentConfig", () => { }); describe("createCursorAgent", () => { - test("returns an AgentFn with explicit workspace", () => { + test("returns an AdapterFn with explicit workspace", () => { const agent = createCursorAgent({ command: "/usr/local/bin/cursor-agent", model: null, @@ -90,7 +90,7 @@ describe("createCursorAgent", () => { expect(typeof agent).toBe("function"); }); - test("returns an AgentFn with null workspace and llmProvider", () => { + test("returns an AdapterFn with null workspace and llmProvider", () => { const agent = createCursorAgent({ command: "/usr/local/bin/cursor-agent", model: null, diff --git a/packages/workflow-agent-cursor/src/index.ts b/packages/workflow-agent-cursor/src/index.ts index 771f3ac..c2b3e85 100644 --- a/packages/workflow-agent-cursor/src/index.ts +++ b/packages/workflow-agent-cursor/src/index.ts @@ -1,6 +1,11 @@ -import type { AgentFn } from "@uncaged/workflow-runtime"; +import type { AdapterFn } from "@uncaged/workflow-runtime"; import { createLogger } from "@uncaged/workflow-util"; -import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent"; +import { + buildThreadInput, + createTextAdapter, + type SpawnCliError, + spawnCli, +} from "@uncaged/workflow-util-agent"; import { extractWorkspacePath } from "./extract-workspace.js"; import type { CursorAgentConfig } from "./types.js"; @@ -29,12 +34,12 @@ function resolveCursorModel(model: string | null): string { } /** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */ -export function createCursorAgent(config: CursorAgentConfig): AgentFn { +export function createCursorAgent(config: CursorAgentConfig): AdapterFn { const modelFlag = resolveCursorModel(config.model); const timeoutMs = config.timeout > 0 ? config.timeout : null; const logger = createLogger({ sink: { kind: "stderr" } }); - return async (ctx) => { + return createTextAdapter(async (ctx, prompt) => { const validated = validateCursorAgentConfig(config); if (!validated.ok) { throw new Error(validated.error); @@ -48,7 +53,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { if (config.llmProvider === null) { throw new Error("cursor-agent: llmProvider is required when workspace is null"); } - const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger); + const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } }; + const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger); if (extracted === null) { throw new Error( "cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.", @@ -58,7 +64,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { } logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`); - const fullPrompt = await buildAgentPrompt(ctx); + const threadInput = await buildThreadInput(ctx); + const fullPrompt = `${prompt}\n\n${threadInput}`; const args = [ "-p", fullPrompt, @@ -79,5 +86,5 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn { throwCursorSpawnError(run.error); } return run.value; - }; + }); } diff --git a/packages/workflow-agent-hermes/__tests__/hermes-agent.test.ts b/packages/workflow-agent-hermes/__tests__/hermes-agent.test.ts index a457a81..fe4dd8e 100644 --- a/packages/workflow-agent-hermes/__tests__/hermes-agent.test.ts +++ b/packages/workflow-agent-hermes/__tests__/hermes-agent.test.ts @@ -37,7 +37,7 @@ describe("validateHermesAgentConfig", () => { }); describe("createHermesAgent", () => { - test("returns an AgentFn even with invalid config (validation deferred to call)", () => { + test("returns an AdapterFn even with invalid config (validation deferred to call)", () => { const agent = createHermesAgent({ command: "/usr/local/bin/hermes", model: null, diff --git a/packages/workflow-agent-hermes/src/index.ts b/packages/workflow-agent-hermes/src/index.ts index 0d3182a..4a0ccff 100644 --- a/packages/workflow-agent-hermes/src/index.ts +++ b/packages/workflow-agent-hermes/src/index.ts @@ -1,5 +1,10 @@ -import type { AgentFn } from "@uncaged/workflow-runtime"; -import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent"; +import type { AdapterFn } from "@uncaged/workflow-runtime"; +import { + buildThreadInput, + createTextAdapter, + type SpawnCliError, + spawnCli, +} from "@uncaged/workflow-util-agent"; import type { HermesAgentConfig } from "./types.js"; import { validateHermesAgentConfig } from "./validate-config.js"; @@ -25,16 +30,17 @@ function throwHermesSpawnError(error: SpawnCliError): never { } /** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */ -export function createHermesAgent(config: HermesAgentConfig): AgentFn { +export function createHermesAgent(config: HermesAgentConfig): AdapterFn { const timeoutMs = config.timeout; - return async (ctx) => { + return createTextAdapter(async (ctx, prompt) => { const validated = validateHermesAgentConfig(config); if (!validated.ok) { throw new Error(validated.error); } - const fullPrompt = await buildAgentPrompt(ctx); + const threadInput = await buildThreadInput(ctx); + const fullPrompt = `${prompt}\n\n${threadInput}`; const args = [ "chat", "-q", @@ -55,5 +61,5 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn { throwHermesSpawnError(run.error); } return run.value; - }; + }); } diff --git a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts index 0a804b8..9d7991e 100644 --- a/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts +++ b/packages/workflow-agent-llm/__tests__/create-llm-adapter.test.ts @@ -1,9 +1,16 @@ import { describe, expect, test } from "bun:test"; -import { type AgentContext, START } from "@uncaged/workflow-runtime"; +import { + type CasStore, + type ExtractFn, + START, + type ThreadContext, + type WorkflowRuntime, +} from "@uncaged/workflow-runtime"; +import * as z from "zod"; import { createLlmAdapter } from "../src/create-llm-adapter.js"; -function makeCtx(userContent: string): AgentContext { +function makeCtx(userContent: string): ThreadContext { return { start: { role: START, @@ -16,14 +23,34 @@ function makeCtx(userContent: string): AgentContext { bundleHash: "TESTHASH00001", steps: [], threadId: "01TEST000000000000000000TR", - currentRole: { name: "planner", systemPrompt: "system instructions" }, }; } +const testSchema = z.object({ summary: z.string() }); + +function makeRuntime(): WorkflowRuntime { + let stored = ""; + const cas: CasStore = { + put: async (content: string) => { + stored = content; + return "HASH001"; + }, + get: async () => stored, + delete: async () => {}, + list: async () => [], + }; + const extract: ExtractFn = async (_schema, _contentHash) => ({ + meta: { summary: "extracted" }, + contentPayload: stored, + refs: [], + }); + return { cas, extract }; +} + describe("createLlmAdapter", () => { const originalFetch = globalThis.fetch; - test("posts system + user (start.content) and returns assistant text", async () => { + test("posts system + user (start.content) and returns typed meta with childThread: null", async () => { globalThis.fetch = (() => Promise.resolve( new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), { @@ -34,11 +61,13 @@ describe("createLlmAdapter", () => { const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const adapter = createLlmAdapter(provider); - const out = await adapter(makeCtx("trigger text")); + const roleFn = adapter("system instructions", testSchema); + const result = await roleFn(makeCtx("trigger text"), makeRuntime()); globalThis.fetch = originalFetch; - expect(out).toBe("model reply"); + expect(result.meta).toEqual({ summary: "extracted" }); + expect(result.childThread).toBeNull(); }); test("throws on non-ok fetch response", async () => { @@ -52,8 +81,9 @@ describe("createLlmAdapter", () => { const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const adapter = createLlmAdapter(provider); + const roleFn = adapter("system", testSchema); - await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:"); + await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:"); globalThis.fetch = originalFetch; }); @@ -62,8 +92,9 @@ describe("createLlmAdapter", () => { const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const adapter = createLlmAdapter(provider); + const roleFn = adapter("system", testSchema); - await expect(adapter(makeCtx("hi"))).rejects.toThrow(); + await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow(); globalThis.fetch = originalFetch; }); }); diff --git a/packages/workflow-agent-llm/package.json b/packages/workflow-agent-llm/package.json index c40ac93..31c84f2 100644 --- a/packages/workflow-agent-llm/package.json +++ b/packages/workflow-agent-llm/package.json @@ -12,6 +12,10 @@ "test": "bun test" }, "dependencies": { - "@uncaged/workflow-runtime": "workspace:*" + "@uncaged/workflow-runtime": "workspace:*", + "@uncaged/workflow-util-agent": "workspace:*" + }, + "devDependencies": { + "zod": "^4.0.0" } } diff --git a/packages/workflow-agent-llm/src/create-llm-adapter.ts b/packages/workflow-agent-llm/src/create-llm-adapter.ts index 508030e..4cbc86a 100644 --- a/packages/workflow-agent-llm/src/create-llm-adapter.ts +++ b/packages/workflow-agent-llm/src/create-llm-adapter.ts @@ -1,11 +1,5 @@ -import { - type AgentContext, - type AgentFn, - err, - type LlmProvider, - ok, - type Result, -} from "@uncaged/workflow-runtime"; +import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime"; +import { createTextAdapter } from "@uncaged/workflow-util-agent"; /** OpenAI chat completion message shape (passed to `/chat/completions`). */ export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; @@ -97,13 +91,13 @@ export async function chatCompletionText(options: { return parseAssistantText(res.value); } -/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */ -export function createLlmAdapter(provider: LlmProvider): AgentFn { - return async (ctx: AgentContext) => { +/** Single-turn chat adapter: system prompt is passed by the workflow engine. */ +export function createLlmAdapter(provider: LlmProvider): AdapterFn { + return createTextAdapter(async (ctx, prompt) => { const result = await chatCompletionText({ provider, messages: [ - { role: "system", content: ctx.currentRole.systemPrompt }, + { role: "system", content: prompt }, { role: "user", content: ctx.start.content }, ], }); @@ -111,5 +105,5 @@ export function createLlmAdapter(provider: LlmProvider): AgentFn { throw new Error(`llm: ${formatLlmChatError(result.error)}`); } return result.value; - }; + }); } diff --git a/packages/workflow-agent-llm/tsconfig.json b/packages/workflow-agent-llm/tsconfig.json index 1187cda..d9141ff 100644 --- a/packages/workflow-agent-llm/tsconfig.json +++ b/packages/workflow-agent-llm/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow-runtime" }] + "references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }] } diff --git a/packages/workflow-execute/src/index.ts b/packages/workflow-execute/src/index.ts index 2131263..682477d 100644 --- a/packages/workflow-execute/src/index.ts +++ b/packages/workflow-execute/src/index.ts @@ -42,4 +42,7 @@ export { llmErrorToCause, llmExtract, } from "./extract/index.js"; +export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js"; + +/** @deprecated Use {@link workflowAdapter} instead. */ export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js"; diff --git a/packages/workflow-execute/src/workflow-adapter.ts b/packages/workflow-execute/src/workflow-adapter.ts new file mode 100644 index 0000000..d3a581c --- /dev/null +++ b/packages/workflow-execute/src/workflow-adapter.ts @@ -0,0 +1,165 @@ +import { join } from "node:path"; +import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas"; +import type { WorkflowConfig } from "@uncaged/workflow-register"; +import { + extractBundleExports, + getRegisteredWorkflow, + readWorkflowRegistry, +} from "@uncaged/workflow-register"; +import type { + AdapterFn, + RoleResult, + ThreadContext, + WorkflowFn, + WorkflowRuntime, +} from "@uncaged/workflow-runtime"; +import { + createLogger, + generateUlid, + getDefaultWorkflowStorageRoot, + getGlobalCasDir, +} from "@uncaged/workflow-util"; +import type * as z from "zod/v4"; +import type { ExecuteThreadIo } from "./engine/index.js"; +import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js"; + +const DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH = 3; + +function workflowAdapterMaxDepth(config: WorkflowConfig | null): number { + return config === null ? DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH : config.maxDepth; +} + +export type WorkflowAdapterOptions = { + /** When `null`, uses `getDefaultWorkflowStorageRoot()`. */ + storageRoot: string | null; +}; + +function resolveStorageRoot(options: WorkflowAdapterOptions | null): string { + if (options !== null && options.storageRoot !== null) { + return options.storageRoot; + } + return getDefaultWorkflowStorageRoot(); +} + +async function readParentHeadState( + storageRoot: string, + ctx: ThreadContext, +): Promise { + const bundleDir = getBundleDir(storageRoot, ctx.bundleHash); + const index = await readThreadsIndex(bundleDir); + const entry = index[ctx.threadId] ?? null; + return entry !== null ? entry.head : null; +} + +/** Resolve the workflow bundle and validate depth limits. */ +async function resolveWorkflowBundle(workflowName: string, storageRoot: string, nextDepth: number) { + const registryResult = await readWorkflowRegistry(storageRoot); + if (!registryResult.ok) { + throw new Error(`failed to read workflow registry: ${registryResult.error.message}`); + } + + const maxDepth = workflowAdapterMaxDepth(registryResult.value.config); + if (nextDepth > maxDepth) { + throw new Error(`workflow adapter depth limit exceeded (max ${maxDepth})`); + } + + const entry = getRegisteredWorkflow(registryResult.value, workflowName); + if (entry === null) { + throw new Error(`workflow "${workflowName}" not found in registry`); + } + + const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`); + const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot }); + if (!bundleExportsResult.ok) { + throw new Error(String(bundleExportsResult.error)); + } + + return { entry, run: bundleExportsResult.value.run }; +} + +/** Execute the child workflow thread and return a summary + root hash. */ +async function runChildThread(params: { + workflowName: string; + storageRoot: string; + ctx: ThreadContext; + run: WorkflowFn; + bundleHash: string; + nextDepth: number; +}) { + const { workflowName, storageRoot, ctx, run, bundleHash, nextDepth } = params; + const childThreadId = generateUlid(Date.now()); + const infoJsonlPath = join(storageRoot, "logs", bundleHash, `${childThreadId}.info.jsonl`); + + const io: ExecuteThreadIo = { + threadId: childThreadId, + hash: bundleHash, + infoJsonlPath, + cas: createCasStore(getGlobalCasDir(storageRoot)), + }; + + const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } }); + const parentHeadState = await readParentHeadState(storageRoot, ctx); + + const result = await executeThread( + run, + workflowName, + { prompt: ctx.start.content, steps: [] }, + { + depth: nextDepth, + parentStateHash: parentHeadState, + signal: new AbortController().signal, + awaitAfterEachYield: async () => {}, + forkSourceThreadId: ctx.threadId, + prefilledDiskSteps: null, + forkContinuation: null, + replayTimestamps: null, + storageRoot, + }, + io, + logger, + ); + + return { + summary: `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`, + rootHash: result.rootHash, + }; +} + +/** + * Returns an {@link AdapterFn} that runs another registered workflow in a new child thread, + * using the parent thread's initial prompt (`ctx.start.content`) as the child prompt. + * + * The child thread's root hash is returned as `childThread` in the result, + * enabling parent→child tracking in the CAS Merkle tree. + */ +export function workflowAdapter( + workflowName: string, + options: WorkflowAdapterOptions | null = null, +): AdapterFn { + return (_prompt: string, schema: z.ZodType) => { + return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise> => { + const storageRoot = resolveStorageRoot(options); + const { entry, run } = await resolveWorkflowBundle(workflowName, storageRoot, ctx.depth + 1); + + try { + const { summary, rootHash } = await runChildThread({ + workflowName, + storageRoot, + ctx, + run, + bundleHash: entry.hash, + nextDepth: ctx.depth + 1, + }); + const contentHash = await putContentNodeWithRefs(runtime.cas, summary, []); + const extracted = await runtime.extract( + schema as z.ZodType>, + contentHash, + ); + return { meta: extracted.meta as T, childThread: rootHash }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`child workflow "${workflowName}" failed: ${message}`); + } + }; + }; +} diff --git a/packages/workflow-execute/src/workflow-as-agent.ts b/packages/workflow-execute/src/workflow-as-agent.ts index 40adf25..7f12ce0 100644 --- a/packages/workflow-execute/src/workflow-as-agent.ts +++ b/packages/workflow-execute/src/workflow-as-agent.ts @@ -1,136 +1,8 @@ -import { join } from "node:path"; -import { createCasStore } from "@uncaged/workflow-cas"; -import type { WorkflowConfig } from "@uncaged/workflow-register"; -import { - extractBundleExports, - getRegisteredWorkflow, - readWorkflowRegistry, -} from "@uncaged/workflow-register"; -import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime"; -import { - createLogger, - generateUlid, - getDefaultWorkflowStorageRoot, - getGlobalCasDir, -} from "@uncaged/workflow-util"; -import type { ExecuteThreadIo } from "./engine/index.js"; -import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js"; - -const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3; - -function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number { - if (config === null) { - return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH; - } - return config.maxDepth; -} - -export type WorkflowAsAgentOptions = { - /** When `null`, uses `getDefaultWorkflowStorageRoot()`. */ - storageRoot: string | null; -}; - -function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string { - if (options !== null && options.storageRoot !== null) { - return options.storageRoot; - } - return getDefaultWorkflowStorageRoot(); -} - -async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise { - const bundleDir = getBundleDir(storageRoot, ctx.bundleHash); - const index = await readThreadsIndex(bundleDir); - const entry = index[ctx.threadId] ?? null; - return entry !== null ? entry.head : null; -} - /** - * Returns an {@link AgentFn} that runs another registered workflow in a new thread, - * using the parent thread's initial prompt (`ctx.start.content`) as the child prompt. + * @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead. + * This module is kept for backward compatibility and will be removed in a future release. */ -export function workflowAsAgent( - workflowName: string, - options: WorkflowAsAgentOptions | null = null, -): AgentFn { - return async (ctx: AgentContext): Promise => { - const nextDepth = ctx.depth + 1; - - const storageRoot = resolveWorkflowAsAgentStorageRoot(options); - - const registryResult = await readWorkflowRegistry(storageRoot); - if (!registryResult.ok) { - return { - output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, - childThread: null, - }; - } - - const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config); - if (nextDepth > maxDepth) { - return { - output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, - childThread: null, - }; - } - - const entry = getRegisteredWorkflow(registryResult.value, workflowName); - if (entry === null) { - return { - output: `ERROR: workflow "${workflowName}" not found in registry`, - childThread: null, - }; - } - - const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`); - const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot }); - if (!bundleExportsResult.ok) { - return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null }; - } - - const input = { - prompt: ctx.start.content, - steps: [], - }; - - const childThreadId = generateUlid(Date.now()); - const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`); - - const io: ExecuteThreadIo = { - threadId: childThreadId, - hash: entry.hash, - infoJsonlPath, - cas: createCasStore(getGlobalCasDir(storageRoot)), - }; - - const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } }); - const signalNever = new AbortController(); - - const parentHeadState = await readParentHeadState(storageRoot, ctx); - - try { - const result = await executeThread( - bundleExportsResult.value.run, - workflowName, - input, - { - depth: nextDepth, - parentStateHash: parentHeadState, - signal: signalNever.signal, - awaitAfterEachYield: async () => {}, - forkSourceThreadId: ctx.threadId, - prefilledDiskSteps: null, - forkContinuation: null, - replayTimestamps: null, - storageRoot, - }, - io, - logger, - ); - const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`; - return { output: summary, childThread: result.rootHash }; - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return { output: `ERROR: ${message}`, childThread: null }; - } - }; -} +export { + type WorkflowAdapterOptions as WorkflowAsAgentOptions, + workflowAdapter as workflowAsAgent, +} from "./workflow-adapter.js"; diff --git a/packages/workflow-protocol/src/index.ts b/packages/workflow-protocol/src/index.ts index 5e6471d..1276383 100644 --- a/packages/workflow-protocol/src/index.ts +++ b/packages/workflow-protocol/src/index.ts @@ -12,10 +12,7 @@ export type { AdapterBinding, AdapterFn, AdvanceOutcome, - AgentBinding, AgentContext, - AgentFn, - AgentFnResult, CasStore, ExtractFn, ExtractResult, diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 5707d8f..72b2a0f 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -143,18 +143,6 @@ export type ExtractFn = >( contentHash: string, ) => Promise>; -/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */ -export type AgentFnResult = string | { output: string; childThread: string | null }; - -/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */ -export type AgentFn = (ctx: AgentContext) => Promise; - -/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */ -export type AgentBinding = { - agent: AgentFn; - overrides: Partial> | null; -}; - // ── Adapter (replaces Agent) ──────────────────────────────────────── export type RoleResult = { meta: T; childThread: string | null }; diff --git a/packages/workflow-runtime/src/index.ts b/packages/workflow-runtime/src/index.ts index 571448f..bdaef83 100644 --- a/packages/workflow-runtime/src/index.ts +++ b/packages/workflow-runtime/src/index.ts @@ -4,10 +4,7 @@ export { err, ok } from "./result.js"; export type { AdapterBinding, AdapterFn, - AgentBinding, AgentContext, - AgentFn, - AgentFnResult, CasStore, ExtractFn, ExtractResult, diff --git a/packages/workflow-runtime/src/types.ts b/packages/workflow-runtime/src/types.ts index 581d718..d78a9d2 100644 --- a/packages/workflow-runtime/src/types.ts +++ b/packages/workflow-runtime/src/types.ts @@ -6,10 +6,7 @@ export type { AdapterBinding, AdapterFn, AdvanceOutcome, - AgentBinding, AgentContext, - AgentFn, - AgentFnResult, CasStore, ExtractFn, ExtractResult, diff --git a/packages/workflow-template-develop/bundle-entry.ts b/packages/workflow-template-develop/bundle-entry.ts index f330b20..5c76fd7 100644 --- a/packages/workflow-template-develop/bundle-entry.ts +++ b/packages/workflow-template-develop/bundle-entry.ts @@ -6,7 +6,6 @@ import { createCursorAgent } from "@uncaged/workflow-agent-cursor"; import { createWorkflow } from "@uncaged/workflow-runtime"; import { optionalEnv, requireEnv } from "@uncaged/workflow-util"; -import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent"; import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js"; const llmProvider = { @@ -18,7 +17,7 @@ const llmProvider = { model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"), }; -const agent = createCursorAgent({ +const adapter = createCursorAgent({ command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"), model: optionalEnv("WORKFLOW_CURSOR_MODEL"), timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT") @@ -28,8 +27,6 @@ const agent = createCursorAgent({ llmProvider, }); -const adapter = wrapAgentAsAdapter(agent); - const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null }); export const descriptor = buildDevelopDescriptor(); diff --git a/packages/workflow-template-solve-issue/bundle-entry.ts b/packages/workflow-template-solve-issue/bundle-entry.ts index 8dabff0..29e70b5 100644 --- a/packages/workflow-template-solve-issue/bundle-entry.ts +++ b/packages/workflow-template-solve-issue/bundle-entry.ts @@ -2,31 +2,25 @@ * solve-issue bundle entry — 小橘 🍊 * * preparer + submitter → hermes agent - * developer → workflow-as-agent (delegates to "develop" workflow) + * developer → workflow adapter (delegates to "develop" workflow) */ import { createHermesAgent } from "@uncaged/workflow-agent-hermes"; -import { workflowAsAgent } from "@uncaged/workflow-execute"; +import { workflowAdapter } from "@uncaged/workflow-execute"; import { createWorkflow } from "@uncaged/workflow-runtime"; import { optionalEnv } from "@uncaged/workflow-util"; -import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent"; import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js"; -const hermesAgent = createHermesAgent({ +const adapter = createHermesAgent({ model: optionalEnv("WORKFLOW_HERMES_MODEL"), timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT") ? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT")) : null, }); -const developerAgent = workflowAsAgent("develop"); - -const adapter = wrapAgentAsAdapter(hermesAgent); -const developerAdapter = wrapAgentAsAdapter(developerAgent); - const wf = createWorkflow(solveIssueWorkflowDefinition, { adapter, overrides: { - developer: developerAdapter, + developer: workflowAdapter("develop"), }, }); diff --git a/packages/workflow-util-agent/src/create-text-adapter.ts b/packages/workflow-util-agent/src/create-text-adapter.ts new file mode 100644 index 0000000..e2b6c13 --- /dev/null +++ b/packages/workflow-util-agent/src/create-text-adapter.ts @@ -0,0 +1,51 @@ +import { putContentNodeWithRefs } from "@uncaged/workflow-cas"; +import type { + AdapterFn, + RoleResult, + ThreadContext, + WorkflowRuntime, +} from "@uncaged/workflow-runtime"; +import type * as z from "zod/v4"; + +/** + * Result from a text-producing agent (CLI spawn, LLM call, etc.). + * `output` is the raw text; `childThread` links to a spawned sub-workflow. + */ +export type TextAdapterResult = { + output: string; + childThread: string | null; +}; + +/** + * A function that produces raw text output given the thread context and + * the system prompt for the current role. + */ +export type TextProducerFn = ( + ctx: ThreadContext, + prompt: string, +) => Promise; + +/** + * Creates an {@link AdapterFn} from a text-producing function. + * + * The adapter: + * 1. Calls the producer with thread context + system prompt + * 2. Stores output in CAS + * 3. Runs the extract phase to produce typed meta + * 4. Returns `{ meta, childThread }` + */ +export function createTextAdapter(producer: TextProducerFn): AdapterFn { + return (prompt: string, schema: z.ZodType) => { + return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise> => { + const result = await producer(ctx, prompt); + const output = typeof result === "string" ? result : result.output; + const childThread = typeof result === "string" ? null : result.childThread; + const contentHash = await putContentNodeWithRefs(runtime.cas, output, []); + const extracted = await runtime.extract( + schema as z.ZodType>, + contentHash, + ); + return { meta: extracted.meta as T, childThread }; + }; + }; +} diff --git a/packages/workflow-util-agent/src/index.ts b/packages/workflow-util-agent/src/index.ts index 323c90a..a0b04f5 100644 --- a/packages/workflow-util-agent/src/index.ts +++ b/packages/workflow-util-agent/src/index.ts @@ -1,4 +1,5 @@ export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js"; +export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js"; +export { createTextAdapter } from "./create-text-adapter.js"; export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js"; export { spawnCli } from "./spawn-cli.js"; -export { wrapAgentAsAdapter } from "./wrap-agent-as-adapter.js"; diff --git a/packages/workflow-util-agent/src/wrap-agent-as-adapter.ts b/packages/workflow-util-agent/src/wrap-agent-as-adapter.ts deleted file mode 100644 index 1830922..0000000 --- a/packages/workflow-util-agent/src/wrap-agent-as-adapter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { putContentNodeWithRefs } from "@uncaged/workflow-cas"; -import type { - AdapterFn, - AgentContext, - AgentFnResult, - RoleResult, - ThreadContext, - WorkflowRuntime, -} from "@uncaged/workflow-runtime"; -import type * as z from "zod/v4"; - -/** - * Wraps a legacy AgentFn into an AdapterFn. - * The agent produces a string (or { output, childThread }); the adapter - * stores the output in CAS, runs extract, and returns typed meta + childThread. - */ -export function wrapAgentAsAdapter( - agentFn: (ctx: AgentContext) => Promise, -): AdapterFn { - return (prompt: string, schema: z.ZodType) => { - return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise> => { - const agentCtx: AgentContext = { - ...ctx, - currentRole: { name: "agent", systemPrompt: prompt }, - }; - const result = await agentFn(agentCtx); - const output = typeof result === "string" ? result : result.output; - const childThread = typeof result === "string" ? null : result.childThread; - const contentHash = await putContentNodeWithRefs(runtime.cas, output, []); - const extracted = await runtime.extract( - schema as z.ZodType>, - contentHash, - ); - return { meta: extracted.meta as T, childThread }; - }; - }; -}