diff --git a/packages/workflow-protocol/src/index.ts b/packages/workflow-protocol/src/index.ts index 7a8d635..2c51b34 100644 --- a/packages/workflow-protocol/src/index.ts +++ b/packages/workflow-protocol/src/index.ts @@ -9,6 +9,8 @@ export type { } from "./cas-types.js"; export type { + AdapterBinding, + AdapterFn, AdvanceOutcome, AgentBinding, AgentContext, @@ -27,6 +29,7 @@ export type { ResolvedModel, Result, RoleDefinition, + RoleFn, RoleMeta, RoleOutput, RoleStep, diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index b7d002e..8ff9643 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -143,15 +143,29 @@ 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 RoleFn = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise; + +export type AdapterFn = (prompt: string, schema: z.ZodType) => RoleFn; + +export type AdapterBinding = { + adapter: AdapterFn; + overrides: Partial> | null; +}; + // ── Workflow Runtime & Definition ────────────────────────────────── export type WorkflowRuntime = { diff --git a/packages/workflow-runtime/src/create-workflow.ts b/packages/workflow-runtime/src/create-workflow.ts index 19b4e5d..1d16db9 100644 --- a/packages/workflow-runtime/src/create-workflow.ts +++ b/packages/workflow-runtime/src/create-workflow.ts @@ -3,11 +3,9 @@ import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js" import type * as z from "zod/v4"; import { + type AdapterBinding, + type AdapterFn, type AdvanceOutcome, - type AgentBinding, - type AgentContext, - type AgentFn, - type AgentFnResult, END, type ModeratorContext, type RoleDefinition, @@ -51,28 +49,18 @@ function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] return out; } -function normalizeAgentResult(result: AgentFnResult): { - output: string; - childThread: string | null; -} { - if (typeof result === "string") { - return { output: result, childThread: null }; - } - return result; -} - -function agentForRole(binding: AgentBinding, roleName: string): AgentFn { +function adapterForRole(binding: AdapterBinding, roleName: string): AdapterFn { const overrides = binding.overrides; - const overrideFn: AgentFn | undefined = + const overrideFn: AdapterFn | undefined = overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined; - return overrideFn !== undefined ? overrideFn : binding.agent; + return overrideFn !== undefined ? overrideFn : binding.adapter; } async function advanceOneRound( def: Pick, "roles"> & { pickNext: (ctx: ModeratorContext) => (keyof M & string) | typeof END; }, - binding: AgentBinding, + binding: AdapterBinding, params: { thread: ModeratorContext; runtime: WorkflowRuntime; @@ -94,37 +82,23 @@ async function advanceOneRound( return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } }; } - const agentCtx: AgentContext = { - ...modCtx, - currentRole: { name: next, systemPrompt: roleDef.systemPrompt }, - }; - - const agent = agentForRole(binding, next); - const agentResult = normalizeAgentResult(await agent(agentCtx as unknown as AgentContext)); - - const agentContentHash = await putContentNodeWithRefs(runtime.cas, agentResult.output, []); - - const extracted = await runtime.extract( - roleDef.schema as z.ZodType>, - agentContentHash, - ); + const adapter = adapterForRole(binding, next); + const roleFn = adapter(roleDef.systemPrompt, roleDef.schema as z.ZodType>); + const meta = await roleFn(modCtx as unknown as ThreadContext, runtime); const refsFromMeta = resolveExtractedRefs( roleDef as unknown as RoleDefinition>, - extracted.meta, + meta, ); - const artifactRefs = mergeUniqueHashes(extracted.refs, refsFromMeta); - const contentHash = - artifactRefs.length === 0 - ? agentContentHash - : await putContentNodeWithRefs(runtime.cas, extracted.contentPayload, artifactRefs); - const refs = artifactRefs.includes(contentHash) ? artifactRefs : [...artifactRefs, contentHash]; + const contentPayload = JSON.stringify(meta); + const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta); + const refs = refsFromMeta.length === 0 ? [contentHash] : [...refsFromMeta, contentHash]; const step = { role: next, contentHash, - meta: extracted.meta, + meta, refs, timestamp: Date.now(), } as RoleStep; @@ -136,22 +110,22 @@ async function advanceOneRound( contentHash: step.contentHash, meta: step.meta, refs: step.refs, - childThread: agentResult.childThread, + childThread: null, }, step, }; } /** - * Binds pure role definitions + moderator table to runtime agents. + * Binds pure role definitions + moderator table to an adapter. * Assign with `export const run = createWorkflow(def, binding)`. * - * Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the - * engine resolves from the workflow registry's `extract` scene. + * The adapter is responsible for returning typed meta directly — no separate + * extract call is needed. */ export function createWorkflow( def: Pick, "roles" | "table">, - binding: AgentBinding, + binding: AdapterBinding, ): WorkflowFn { const pickNext = tableToModerator(def.table); const loopDef = { roles: def.roles, pickNext }; diff --git a/packages/workflow-runtime/src/index.ts b/packages/workflow-runtime/src/index.ts index aad90f1..ef2184d 100644 --- a/packages/workflow-runtime/src/index.ts +++ b/packages/workflow-runtime/src/index.ts @@ -2,6 +2,8 @@ export { buildThreadContext } from "./build-context.js"; export { createWorkflow } from "./create-workflow.js"; export { err, ok } from "./result.js"; export type { + AdapterBinding, + AdapterFn, AgentBinding, AgentContext, AgentFn, @@ -17,6 +19,7 @@ export type { ModeratorTransition, Result, RoleDefinition, + RoleFn, RoleMeta, RoleOutput, RoleStep, diff --git a/packages/workflow-runtime/src/types.ts b/packages/workflow-runtime/src/types.ts index 9e8425b..c171028 100644 --- a/packages/workflow-runtime/src/types.ts +++ b/packages/workflow-runtime/src/types.ts @@ -3,6 +3,8 @@ // imports from "@uncaged/workflow-runtime" continues to work. export type { + AdapterBinding, + AdapterFn, AdvanceOutcome, AgentBinding, AgentContext, @@ -21,6 +23,7 @@ export type { ResolvedModel, Result, RoleDefinition, + RoleFn, RoleMeta, RoleOutput, RoleStep, diff --git a/packages/workflow-template-develop/bundle-entry.ts b/packages/workflow-template-develop/bundle-entry.ts index 82073a9..17860a5 100644 --- a/packages/workflow-template-develop/bundle-entry.ts +++ b/packages/workflow-template-develop/bundle-entry.ts @@ -4,7 +4,10 @@ * All roles use cursor-agent with workspace auto-extracted from context. */ import { createCursorAgent } from "@uncaged/workflow-agent-cursor"; +import { putContentNodeWithRefs } from "@uncaged/workflow-cas"; +import type { AdapterFn, AgentContext, AgentFnResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime"; import { createWorkflow } from "@uncaged/workflow-runtime"; +import type * as z from "zod/v4"; import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js"; function requireEnv(name: string): string { @@ -40,7 +43,22 @@ const agent = createCursorAgent({ llmProvider, }); -const wf = createWorkflow(developWorkflowDefinition, { agent, overrides: null }); +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 contentHash = await putContentNodeWithRefs(runtime.cas, output, []); + const extracted = await runtime.extract(schema as z.ZodType>, contentHash); + return extracted.meta as T; + }; + }; +} + +const adapter = wrapAgentAsAdapter(agent); + +const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null }); export const descriptor = buildDevelopDescriptor(); export const run = wf; diff --git a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts index 6d2d1a1..62dc7c3 100644 --- a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -7,12 +7,16 @@ import { createExtract } from "@uncaged/workflow-execute"; import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js"; import { validateWorkflowDescriptor } from "@uncaged/workflow-register"; import { + type AdapterFn, createWorkflow, END, type ModeratorContext, type RoleStep, START, + type ThreadContext, + type WorkflowRuntime, } from "@uncaged/workflow-runtime"; +import type * as z from "zod/v4"; import { buildSolveIssueDescriptor } from "../src/descriptor.js"; import type { DeveloperMeta } from "../src/developer.js"; import { solveIssueTable, solveIssueWorkflowDefinition } from "../src/index.js"; @@ -21,86 +25,6 @@ import type { SolveIssueMeta } from "../src/roles.js"; const solveIssueModerator = tableToModerator(solveIssueTable); -function jsonResponse(payload: Record): Response { - return new Response(JSON.stringify(payload), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} - -function buildPlainJsonResponse(args: Record): Response { - return jsonResponse({ - choices: [{ message: { content: JSON.stringify(args) } }], - }); -} - -function installMockChatCompletions(sequence: ReadonlyArray>): () => void { - const origFetch = globalThis.fetch; - let i = 0; - const mockFetch = async ( - _input: Parameters[0], - _init?: RequestInit, - ): Promise => { - const args = sequence[i] ?? sequence[sequence.length - 1]; - if (args === undefined) { - throw new Error("installMockChatCompletions: empty sequence"); - } - i += 1; - return buildPlainJsonResponse(args); - }; - globalThis.fetch = Object.assign(mockFetch, { - preconnect: origFetch.preconnect.bind(origFetch), - }) as typeof fetch; - return () => { - globalThis.fetch = origFetch; - }; -} - -function buildToolCallResponse(args: Record): Response { - return jsonResponse({ - choices: [ - { - message: { - tool_calls: [ - { - id: "tc_extract_1", - type: "function", - function: { - name: "extract", - arguments: JSON.stringify(args), - }, - }, - ], - }, - }, - ], - }); -} - -function installMockToolCallCompletions( - sequence: ReadonlyArray>, -): () => void { - const origFetch = globalThis.fetch; - let i = 0; - const mockFetch = async ( - _input: Parameters[0], - _init?: RequestInit, - ): Promise => { - const args = sequence[i] ?? sequence[sequence.length - 1]; - if (args === undefined) { - throw new Error("installMockToolCallCompletions: empty sequence"); - } - i += 1; - return buildToolCallResponse(args); - }; - globalThis.fetch = Object.assign(mockFetch, { - preconnect: origFetch.preconnect.bind(origFetch), - }) as typeof fetch; - return () => { - globalThis.fetch = origFetch; - }; -} - function makeStart(): ModeratorContext["start"] { return { role: START, @@ -168,17 +92,6 @@ function submitterStep(meta: SubmitterMeta): RoleStep { }; } -function createStubExtract(casDir: string) { - return createExtract( - { - baseUrl: "http://127.0.0.1:9", - apiKey: "", - model: "test", - }, - { cas: createCasStore(casDir) }, - ); -} - function makeThread(prompt: string) { return { threadId: "01TEST000000000000000000TR", @@ -195,6 +108,35 @@ function makeThread(prompt: string) { }; } +/** Creates an AdapterFn that returns a fixed sequence of meta values. */ +function createSequenceAdapter(sequence: ReadonlyArray>): AdapterFn { + let i = 0; + return (_prompt: string, _schema: z.ZodType) => { + return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise => { + const meta = sequence[i] ?? sequence[sequence.length - 1]; + if (meta === undefined) { + throw new Error("createSequenceAdapter: empty sequence"); + } + i += 1; + return meta as T; + }; + }; +} + +/** Creates an AdapterFn that tracks calls and returns fixed meta. */ +function createTrackingAdapter( + name: string, + calls: string[], + meta: Record, +): AdapterFn { + return (_prompt: string, _schema: z.ZodType) => { + return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise => { + calls.push(name); + return meta as T; + }; + }; +} + describe("solveIssueModerator", () => { test("routes initial → preparer → developer → submitter → END", () => { expect(solveIssueModerator(makeCtx([]))).toBe("preparer"); @@ -227,8 +169,6 @@ describe("solveIssueModerator", () => { }); test("returns END for any unexpected last step (defensive)", () => { - // A submitter step with a pseudo-unknown future status would still be - // routed to END, since the moderator is a closed switch over known roles. expect( solveIssueModerator( makeCtx([ @@ -242,19 +182,16 @@ describe("solveIssueModerator", () => { }); describe("solveIssueWorkflowDefinition + createWorkflow", () => { - let restoreFetch: (() => void) | null = null; let casDir: string | undefined; afterEach(async () => { - restoreFetch?.(); - restoreFetch = null; if (casDir !== undefined) { await rm(casDir, { recursive: true, force: true }).catch(() => {}); casDir = undefined; } }); - test("structured extraction yields preparer meta from mocked chat completions", async () => { + test("adapter yields preparer meta directly", async () => { const EXPECT_PREPARER_META: PreparerMeta = { repoPath: "/home/user/repos/test", defaultBranch: "main", @@ -266,18 +203,21 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => { buildCommand: "bun run build", }, }; - restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]); casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); const cas = createCasStore(casDir); + const adapter = createSequenceAdapter([EXPECT_PREPARER_META]); const run = createWorkflow(solveIssueWorkflowDefinition, { - agent: async () => "", - overrides: { developer: async () => "stub-root-hash" }, + adapter, + overrides: null, }); const gen = run(makeThread("task"), { cas, - extract: createStubExtract(casDir), + extract: createExtract( + { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, + { cas }, + ), }); const first = await gen.next(); expect(first.done).toBe(false); @@ -288,41 +228,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => { expect(first.value.meta).toEqual(EXPECT_PREPARER_META); }); - test("structured extraction also accepts tool_calls extraction path", async () => { - const EXPECT_PREPARER_META: PreparerMeta = { - repoPath: "/home/user/repos/tool-call", - defaultBranch: "main", - conventions: null, - toolchain: { - packageManager: "bun", - testCommand: "bun test", - lintCommand: null, - buildCommand: "bun run build", - }, - }; - restoreFetch = installMockToolCallCompletions([EXPECT_PREPARER_META]); - - casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); - const cas = createCasStore(casDir); - - const run = createWorkflow(solveIssueWorkflowDefinition, { - agent: async () => "", - overrides: { developer: async () => "stub-root-hash" }, - }); - const gen = run(makeThread("task"), { - cas, - extract: createStubExtract(casDir), - }); - const first = await gen.next(); - expect(first.done).toBe(false); - if (first.done) { - throw new Error("expected yield"); - } - expect(first.value.role).toBe("preparer"); - expect(first.value.meta).toEqual(EXPECT_PREPARER_META); - }); - - test("per-role agent overrides default", async () => { + test("per-role adapter overrides default", async () => { const PREPARER_META: PreparerMeta = { repoPath: "/tmp/r", defaultBranch: "main", @@ -339,35 +245,25 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => { status: "submitted", prUrl: "https://github.com/example/repo/pull/2", }; - restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]); casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); const cas = createCasStore(casDir); const calls: string[] = []; const run = createWorkflow(solveIssueWorkflowDefinition, { - agent: async () => { - calls.push("default"); - return ""; - }, + adapter: createTrackingAdapter("default", calls, PREPARER_META), overrides: { - preparer: async () => { - calls.push("preparer"); - return ""; - }, - developer: async () => { - calls.push("developer"); - return "stub-root-hash"; - }, - submitter: async () => { - calls.push("submitter"); - return ""; - }, + preparer: createTrackingAdapter("preparer", calls, PREPARER_META), + developer: createTrackingAdapter("developer", calls, DEVELOPER_META), + submitter: createTrackingAdapter("submitter", calls, SUBMITTER_META), }, }); const gen = run(makeThread("task"), { cas, - extract: createStubExtract(casDir), + extract: createExtract( + { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, + { cas }, + ), }); await gen.next(); expect(calls).toEqual(["preparer"]); diff --git a/packages/workflow-template-solve-issue/bundle-entry.ts b/packages/workflow-template-solve-issue/bundle-entry.ts index 09b3dd9..2efd8db 100644 --- a/packages/workflow-template-solve-issue/bundle-entry.ts +++ b/packages/workflow-template-solve-issue/bundle-entry.ts @@ -5,8 +5,11 @@ * developer → workflow-as-agent (delegates to "develop" workflow) */ import { createHermesAgent } from "@uncaged/workflow-agent-hermes"; +import { putContentNodeWithRefs } from "@uncaged/workflow-cas"; import { workflowAsAgent } from "@uncaged/workflow-execute"; +import type { AdapterFn, AgentContext, AgentFnResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime"; import { createWorkflow } from "@uncaged/workflow-runtime"; +import type * as z from "zod/v4"; import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js"; function optionalEnv(name: string): string | null { @@ -17,6 +20,19 @@ function optionalEnv(name: string): string | null { return value; } +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 contentHash = await putContentNodeWithRefs(runtime.cas, output, []); + const extracted = await runtime.extract(schema as z.ZodType>, contentHash); + return extracted.meta as T; + }; + }; +} + const hermesAgent = createHermesAgent({ model: optionalEnv("WORKFLOW_HERMES_MODEL"), timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT") @@ -26,10 +42,13 @@ const hermesAgent = createHermesAgent({ const developerAgent = workflowAsAgent("develop"); +const adapter = wrapAgentAsAdapter(hermesAgent); +const developerAdapter = wrapAgentAsAdapter(developerAgent); + const wf = createWorkflow(solveIssueWorkflowDefinition, { - agent: hermesAgent, + adapter, overrides: { - developer: developerAgent, + developer: developerAdapter, }, }); diff --git a/packages/workflow-util-agent/src/build-agent-prompt.ts b/packages/workflow-util-agent/src/build-agent-prompt.ts index 2851f26..9d217c0 100644 --- a/packages/workflow-util-agent/src/build-agent-prompt.ts +++ b/packages/workflow-util-agent/src/build-agent-prompt.ts @@ -1,10 +1,11 @@ -import type { AgentContext } from "@uncaged/workflow-runtime"; +import type { AgentContext, ThreadContext } from "@uncaged/workflow-runtime"; -/** Builds the full agent prompt: system instructions plus summarized thread history. */ -export async function buildAgentPrompt(ctx: AgentContext): Promise { +/** + * Builds a user-message string from thread context: task, previous steps, and tool hints. + * Does NOT include a system prompt — that is passed separately via the adapter. + */ +export async function buildThreadInput(ctx: ThreadContext): Promise { const lines: string[] = []; - lines.push(ctx.currentRole.systemPrompt); - lines.push(""); if (ctx.start.parentState !== null) { lines.push("## Parent Context"); @@ -58,3 +59,12 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise { return lines.join("\n"); } + +/** + * @deprecated Use {@link buildThreadInput} instead. This wrapper prepends the system prompt + * from `ctx.currentRole` for backward compatibility with existing agents. + */ +export async function buildAgentPrompt(ctx: AgentContext): Promise { + const threadInput = await buildThreadInput(ctx); + return `${ctx.currentRole.systemPrompt}\n\n${threadInput}`; +} diff --git a/packages/workflow-util-agent/src/index.ts b/packages/workflow-util-agent/src/index.ts index 7d083c7..042d418 100644 --- a/packages/workflow-util-agent/src/index.ts +++ b/packages/workflow-util-agent/src/index.ts @@ -1,3 +1,3 @@ -export { buildAgentPrompt } from "./build-agent-prompt.js"; +export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js"; export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js"; export { spawnCli } from "./spawn-cli.js";