feat(protocol): add AdapterFn/RoleFn/AdapterBinding, refactor createWorkflow to use AdapterBinding (#222)

- Add RoleFn<T>, AdapterFn, AdapterBinding types to workflow-protocol
- Mark AgentFn, AgentFnResult, AgentBinding as @deprecated
- Refactor createWorkflow to accept AdapterBinding instead of AgentBinding
- Adapter returns typed meta directly — no more extract call in workflow loop
- Add buildThreadInput (ThreadContext-based), keep buildAgentPrompt as deprecated wrapper
- Update template bundle-entries to wrap AgentFn as AdapterFn
- Update solve-issue tests to use AdapterFn directly
This commit is contained in:
2026-05-13 02:27:36 +00:00
parent 11ba185fef
commit bebf4aad45
10 changed files with 148 additions and 208 deletions
+3
View File
@@ -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,
+14
View File
@@ -143,15 +143,29 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
/** @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<AgentFnResult>;
/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<T>;
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
export type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
export type WorkflowRuntime = {
@@ -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<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles"> & {
pickNext: (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
},
binding: AgentBinding,
binding: AdapterBinding,
params: {
thread: ModeratorContext<M>;
runtime: WorkflowRuntime;
@@ -94,37 +82,23 @@ async function advanceOneRound<M extends RoleMeta>(
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const agentCtx: AgentContext<M> = {
...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<Record<string, unknown>>,
agentContentHash,
);
const adapter = adapterForRole(binding, next);
const roleFn = adapter(roleDef.systemPrompt, roleDef.schema as z.ZodType<Record<string, unknown>>);
const meta = await roleFn(modCtx as unknown as ThreadContext, runtime);
const refsFromMeta = resolveExtractedRefs(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
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<M>;
@@ -136,22 +110,22 @@ async function advanceOneRound<M extends RoleMeta>(
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<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "table">,
binding: AgentBinding,
binding: AdapterBinding,
): WorkflowFn {
const pickNext = tableToModerator(def.table);
const loopDef = { roles: def.roles, pickNext };
+3
View File
@@ -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,
+3
View File
@@ -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,
@@ -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<AgentFnResult>): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<T> => {
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<Record<string, unknown>>, 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;
@@ -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<string, unknown>): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function buildPlainJsonResponse(args: Record<string, unknown>): Response {
return jsonResponse({
choices: [{ message: { content: JSON.stringify(args) } }],
});
}
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
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<string, unknown>): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
id: "tc_extract_1",
type: "function",
function: {
name: "extract",
arguments: JSON.stringify(args),
},
},
],
},
},
],
});
}
function installMockToolCallCompletions(
sequence: ReadonlyArray<Record<string, unknown>>,
): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
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<SolveIssueMeta>["start"] {
return {
role: START,
@@ -168,17 +92,6 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
};
}
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<Record<string, unknown>>): AdapterFn {
let i = 0;
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<T> => {
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<string, unknown>,
): AdapterFn {
return <T>(_prompt: string, _schema: z.ZodType<T>) => {
return async (_ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<T> => {
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"]);
@@ -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<AgentFnResult>): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<T> => {
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<Record<string, unknown>>, 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,
},
});
@@ -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<string> {
/**
* 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<string> {
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<string> {
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<string> {
const threadInput = await buildThreadInput(ctx);
return `${ctx.currentRole.systemPrompt}\n\n${threadInput}`;
}
+1 -1
View File
@@ -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";