refactor: all-agentic architecture — roles as pure data, agent binding at runtime

BREAKING: Major architecture change.

- RoleDefinition = pure data (systemPrompt + schema + dryRunMeta)
- AgentFn = (ctx: ThreadContext) => Promise<string>, reads ctx.currentRole
- WorkflowDefinition decoupled from agents, bound via AgentBinding at runtime
- createWorkflow(def, binding, extract) replaces createRoleModerator
- Meta extraction moved into engine loop
- Delete workflow-util-role package (createRole, decorators, extract all gone)
- Role packages become pure data exports
- Agent packages updated to single-arg AgentFn

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 14:14:33 +00:00
parent fce2bf7441
commit fa9163e462
52 changed files with 434 additions and 1565 deletions
+16 -7
View File
@@ -1,4 +1,4 @@
import { createRoleModerator, END, type RoleDefinition } from "@uncaged/workflow";
import { createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
type Roles = {
@@ -25,16 +25,25 @@ export const descriptor = {
const greeter: RoleDefinition<Roles["greeter"]> = {
description: "Generates a greeting",
systemPrompt: "You greet the user briefly.",
schema: greeterMetaSchema,
run: async (ctx) => ({
content: `Hello, ${ctx.start.content}`,
meta: { greeting: "Hello!" },
}),
dryRunMeta: { greeting: "Hello!" },
};
export const run = createRoleModerator<Roles>({
const extract = {
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "" },
dryRun: true,
} as const;
export const run = createWorkflow<Roles>(
{
roles: { greeter },
moderator(ctx) {
return ctx.steps.length === 0 ? "greeter" : END;
},
});
},
{
agent: async (ctx) => `Hello, ${ctx.start.content}`,
},
extract,
);
+2 -2
View File
@@ -37,8 +37,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => {
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
return async (ctx) => {
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
const args = [
"-p",
fullPrompt,
+2 -2
View File
@@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => {
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
return async (ctx) => {
const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
const args = [
"chat",
"-q",
@@ -13,6 +13,7 @@ function makeCtx(userContent: string): ThreadContext {
},
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
};
}
@@ -30,7 +31,7 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const out = await adapter(makeCtx("trigger text"), "system instructions");
const out = await adapter(makeCtx("trigger text"));
globalThis.fetch = originalFetch;
@@ -49,7 +50,7 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:");
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
globalThis.fetch = originalFetch;
});
@@ -59,7 +60,7 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow();
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
globalThis.fetch = originalFetch;
});
});
+1 -6
View File
@@ -4,16 +4,11 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
"@uncaged/workflow": "workspace:*"
}
}
@@ -1,6 +1,14 @@
import { type AgentFn, err, ok, type Result, type ThreadContext } from "@uncaged/workflow";
import {
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
type ThreadContext,
} from "@uncaged/workflow";
import type { LlmMessage, LlmProvider } from "@uncaged/workflow-util-role";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
export type LlmChatError =
| { kind: "http_error"; status: number; body: string }
@@ -89,13 +97,13 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system comes from `createRole` prompt; user is the thread start frame. */
/** Single-turn chat adapter: system prompt comes from {@link ThreadContext.currentRole}. */
export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: ThreadContext, systemPrompt: string) => {
return async (ctx: ThreadContext) => {
const result = await chatCompletionText({
provider,
messages: [
{ role: "system", content: systemPrompt },
{ role: "system", content: ctx.currentRole.systemPrompt },
{ role: "user", content: ctx.start.content },
],
});
+4 -18
View File
@@ -1,20 +1,6 @@
export {
type CreateRoleArgs,
createRole,
decorateRole,
extractMetaOrThrow,
type LlmError,
type LlmExtractArgs,
chatCompletionText,
createLlmAdapter,
type LlmChatError,
type LlmMessage,
type LlmProvider,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
type MetaExtractConfig,
type OnFailOptions,
onFail,
type RoleDecorator,
type WithDryRunOptions,
withDryRun,
} from "@uncaged/workflow-util-role";
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
} from "./create-llm-adapter.js";
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
"references": [{ "path": "../workflow" }]
}
@@ -10,8 +10,6 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
+12 -38
View File
@@ -1,6 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const coderMetaSchema = z.object({
@@ -11,41 +9,17 @@ export const coderMetaSchema = z.object({
export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type CoderConfig = {
cwd: string;
};
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
Report which phase you completed. List the files you changed and summarize what you did.`;
export const DEFAULT_CODER_CONFIG: CoderConfig = {
cwd: ".",
};
function coderSystemPrompt(config: CoderConfig): string {
return `You are a **coder**. The project is at \`${config.cwd}\`.
Read the thread: the planner produced ordered **phases**. Identify the **next** phase that is not yet completed according to prior coder steps (each coder step reports a completedPhase).
Implement **only that phase** — do not tackle multiple phases in one turn unless the planner defined a single phase. Follow project conventions; summarize what changed and list touched files.
When done with the phase you worked on, set **completedPhase** to that phase's **name** exactly as given by the planner.`;
}
/**
* Coder role: implements the next incomplete planner phase and reports structured completion metadata.
*/
export function createCoderRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CoderMeta },
config: CoderConfig = DEFAULT_CODER_CONFIG,
): Role<CoderMeta> {
return createRole({
name: "coder",
export const coderRole: RoleDefinition<CoderMeta> = {
description:
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: CODER_SYSTEM,
schema: coderMetaSchema,
systemPrompt: coderSystemPrompt(config),
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
dryRunMeta: {
completedPhase: "phase-1",
filesChanged: [],
summary: "",
},
});
}
};
+1 -7
View File
@@ -1,7 +1 @@
export {
type CoderConfig,
type CoderMeta,
coderMetaSchema,
createCoderRole,
DEFAULT_CODER_CONFIG,
} from "./coder.js";
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
+1 -5
View File
@@ -6,9 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-agent-llm" },
{ "path": "../workflow-util-role" }
]
"references": [{ "path": "../workflow" }]
}
@@ -1,135 +1,15 @@
import { describe, expect, spyOn, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import * as utilRole from "@uncaged/workflow-util-role";
import { committerMetaSchema, committerRole } from "../src/committer.js";
import { createCommitterRole } from "../src/committer.js";
function makeCtx(): ThreadContext {
return {
threadId: "01TEST000000000000000000TR",
start: {
role: START,
content: "do thing",
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
};
}
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
const dryRunMeta = {
status: "committed" as const,
branch: "dry-run/placeholder",
commitSha: "0000000",
};
describe("createCommitterRole", () => {
test("dry-run skips pipeline", async () => {
const agent: AgentFn = async () => {
throw new Error("agent should not run");
};
const role = createCommitterRole(agent, {
provider,
dryRun: true,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.content).toBe("[dry-run] committer skipped");
expect(out.meta).toEqual(dryRunMeta);
describe("committerRole", () => {
test("dryRunMeta validates against schema", () => {
const parsed = committerMetaSchema.safeParse(committerRole.dryRunMeta);
expect(parsed.success).toBe(true);
});
test("returns committed meta when extraction succeeds", async () => {
const committed = {
status: "committed" as const,
branch: "feat/widget",
commitSha: "deadbeef".repeat(5).slice(0, 40),
};
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(committed);
const agent: AgentFn = async (_ctx, prompt) =>
`Created branch ${committed.branch}, pushed. SHA ${committed.commitSha}.\n${prompt.slice(0, 80)}`;
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(committed);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns failed meta when extraction reports failure", async () => {
const failed = {
status: "recoverable" as const,
error: "working tree clean; nothing to commit",
logRef: null as string | null,
};
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
const agent: AgentFn = async () => "git status shows no changes; skipping branch and commit.";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(failed);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns failed meta with logRef when extraction includes it", async () => {
const failed = {
status: "recoverable" as const,
error: "push rejected",
logRef: "LOGREF01",
};
spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
const agent: AgentFn = async () => "Remote rejected non-fast-forward.";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(failed);
});
test("onFail wraps extraction errors", async () => {
spyOn(utilRole, "extractMetaOrThrow").mockRejectedValue(
new Error("structured extraction failed"),
);
const agent: AgentFn = async () => "opaque agent output";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual({
status: "unrecoverable",
error: "committer role threw before structured result",
logRef: null,
});
expect(out.content).toContain("committer failed:");
expect(out.content).toContain("structured extraction failed");
test("exposes generic committer system prompt", () => {
expect(committerRole.systemPrompt).toContain("git committer");
expect(committerRole.systemPrompt).not.toContain("project is at");
});
});
@@ -10,7 +10,6 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,11 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import {
createRole,
decorateRole,
type LlmProvider,
onFail,
withDryRun,
} from "@uncaged/workflow-util-role";
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const committerMetaSchema = z.discriminatedUnion("status", [
@@ -28,69 +21,17 @@ export const committerMetaSchema = z.discriminatedUnion("status", [
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
export type CommitterConfig = {
cwd: string;
};
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
Do not attempt to fix failures yourself.`;
export const DEFAULT_COMMITTER_CONFIG: CommitterConfig = {
cwd: ".",
};
const DRY_RUN_COMMITTED_META: CommitterMeta = {
export const committerRole: RoleDefinition<CommitterMeta> = {
description: "Creates branch, commits, and pushes when review passes.",
systemPrompt: COMMITTER_SYSTEM,
schema: committerMetaSchema,
dryRunMeta: {
status: "committed",
branch: "dry-run/placeholder",
commitSha: "0000000",
};
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
function committerSystemPrompt(config: CommitterConfig): string {
return `You are the git committer for this workflow. The project is at \`${config.cwd}\`.
## Task
Create a branch, commit the changes, and push. Report whether the push succeeded or failed, the branch name, and the commit SHA.
## On failure
If any git operation fails, **do not attempt to fix it yourself**. Capture the key error output and classify it:
- **Recoverable**: failures that a coder can fix (lint/test hook rejection, merge conflict, commit validation errors)
- **Unrecoverable**: failures beyond code changes (no push permission, remote not found, authentication denied, disk full)`;
}
/**
* Git committer role: the agent runs git (branch, commit, push); structured extraction yields {@link CommitterMeta}.
* Dry-run skips the agent and returns a stable committed placeholder; unexpected throws yield `status: "failed"`.
*/
export function createCommitterRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterMeta },
config: CommitterConfig = DEFAULT_COMMITTER_CONFIG,
): Role<CommitterMeta> {
const inner: Role<CommitterMeta> = createRole({
name: "committer",
schema: committerMetaSchema,
systemPrompt: committerSystemPrompt(config),
agent: adapter,
extract,
});
return decorateRole(inner, [
withDryRun<CommitterMeta>({
label: "committer",
meta: DRY_RUN_COMMITTED_META,
dryRun: resolveExtractDryRun(extract.dryRun),
}),
onFail<CommitterMeta>({
label: "committer",
meta: {
status: "unrecoverable",
error: "committer role threw before structured result",
logRef: null,
},
}),
]);
}
};
@@ -1,7 +1 @@
export {
type CommitterConfig,
type CommitterMeta,
committerMetaSchema,
createCommitterRole,
DEFAULT_COMMITTER_CONFIG,
} from "./committer.js";
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
@@ -3,13 +3,8 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true,
"types": ["bun-types"]
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-util-role" }
]
"references": [{ "path": "../workflow" }]
}
@@ -10,8 +10,6 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
export {
createPlannerRole,
DEFAULT_PLANNER_CONFIG,
type PlannerConfig,
type PlannerMeta,
phaseSchema,
plannerMetaSchema,
plannerRole,
} from "./planner.js";
+7 -26
View File
@@ -1,6 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const phaseSchema = z.object({
@@ -15,34 +13,17 @@ export const plannerMetaSchema = z.object({
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
/** Reserved for future planner options; empty for now. */
export type PlannerConfig = Record<string, never>;
export const DEFAULT_PLANNER_CONFIG: PlannerConfig = {};
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
Each phase must have: a short **name** (stable identifier), a **description** of what to do in that phase, and **acceptance** criteria for when that phase is done.
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases. Do not emit separate file lists or a free-form "approach" field — put that detail inside phase descriptions.`;
/**
* Planner role: produces ordered implementation phases for the coder to execute sequentially.
*/
export function createPlannerRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta },
_config: PlannerConfig = DEFAULT_PLANNER_CONFIG,
): Role<PlannerMeta> {
return createRole({
name: "planner",
schema: plannerMetaSchema,
export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
schema: plannerMetaSchema,
dryRunMeta: {
phases: [{ name: "phase-1", description: "placeholder", acceptance: "placeholder" }],
},
});
}
};
+1 -5
View File
@@ -6,9 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-agent-llm" },
{ "path": "../workflow-util-role" }
]
"references": [{ "path": "../workflow" }]
}
@@ -1,113 +1,15 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js";
function toolCallResponse(argsJson: string): Response {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: argsJson,
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
function makeCtx(): ThreadContext {
return {
threadId: "01TEST000000000000000000TR",
start: {
role: START,
content: "task",
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
};
}
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
describe("createReviewerRole", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
mock.restore();
describe("reviewerRole", () => {
test("dryRunMeta validates against schema", () => {
const parsed = reviewerMetaSchema.safeParse(reviewerRole.dryRunMeta);
expect(parsed.success).toBe(true);
});
test("approved verdict", async () => {
globalThis.fetch = (() =>
Promise.resolve(
toolCallResponse(JSON.stringify({ status: "approved" })),
)) as unknown as typeof fetch;
const agent: AgentFn = async (_ctx, prompt) => {
expect(prompt).toContain("code reviewer");
expect(prompt).toContain(DEFAULT_REVIEWER_CONFIG.cwd);
return "review done";
};
const role = createReviewerRole(agent, {
provider,
dryRun: null,
dryRunMeta: { status: "approved" },
});
const out = await role(makeCtx());
expect(out.meta).toEqual({ status: "approved" });
});
test("rejected verdict with issues", async () => {
globalThis.fetch = (() =>
Promise.resolve(
toolCallResponse(JSON.stringify({ status: "rejected", issues: ["secrets in code"] })),
)) as unknown as typeof fetch;
const agent: AgentFn = async () => "found problems";
const role = createReviewerRole(agent, {
provider,
dryRun: null,
dryRunMeta: { status: "approved" },
});
const out = await role(makeCtx());
expect(out.meta).toEqual({ status: "rejected", issues: ["secrets in code"] });
});
test("system prompt includes configured cwd (thread CLI hint comes from agent layer)", async () => {
globalThis.fetch = (() =>
Promise.resolve(
toolCallResponse(JSON.stringify({ status: "approved" })),
)) as unknown as typeof fetch;
let seen = "";
const agent: AgentFn = async (_ctx, prompt) => {
seen = prompt;
return "x";
};
const role = createReviewerRole(
agent,
{ provider, dryRun: null, dryRunMeta: { status: "approved" } },
{ cwd: "/proj" },
);
await role(makeCtx());
expect(seen).toContain("/proj");
expect(seen).toContain("code reviewer");
expect(seen).not.toContain("uncaged-workflow thread");
test("system prompt is generic (no cwd)", () => {
expect(reviewerRole.systemPrompt).toContain("code reviewer");
expect(reviewerRole.systemPrompt).not.toContain("project is at");
});
});
@@ -10,8 +10,6 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
}
}
+1 -7
View File
@@ -1,7 +1 @@
export {
createReviewerRole,
DEFAULT_REVIEWER_CONFIG,
type ReviewerConfig,
type ReviewerMeta,
reviewerMetaSchema,
} from "./reviewer.js";
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
@@ -1,6 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const reviewerMetaSchema = z.discriminatedUnion("status", [
@@ -14,45 +12,12 @@ export const reviewerMetaSchema = z.discriminatedUnion("status", [
]);
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
export type ReviewerConfig = {
cwd: string;
};
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
Only reject for blocking issues. End with your verdict.`;
export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
cwd: ".",
};
function reviewerPrompt(config: ReviewerConfig): string {
const { cwd } = config;
return `You are a code reviewer. The project is at \`${cwd}\`.
## Task
Review the current git diff in \`${cwd}\`. Give a clear **approve** or **reject** verdict.
Only reject for **blocking issues** — things that must be fixed before merge. Do not mention minor style preferences or non-blocking suggestions; they will be ignored.
End with your verdict — clearly state whether the code is approved or rejected, and if rejected, list the blocking issues.`;
}
/**
* Code review role: agent inspects git diffs; structured extract yields approve/reject verdict.
*/
export function createReviewerRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: ReviewerMeta },
config: ReviewerConfig = DEFAULT_REVIEWER_CONFIG,
): Role<ReviewerMeta> {
return createRole({
name: "reviewer",
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: REVIEWER_SYSTEM,
schema: reviewerMetaSchema,
systemPrompt: reviewerPrompt(config),
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
},
});
}
dryRunMeta: { status: "approved" },
};
@@ -6,9 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-agent-llm" },
{ "path": "../workflow-util-role" }
]
"references": [{ "path": "../workflow" }]
}
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import {
type AgentFn,
END,
type RoleStep,
START,
@@ -11,8 +10,8 @@ import {
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import { solveIssueModerator } from "../src/moderator.js";
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
import { createSolveIssueRun, plannerRole, solveIssueModerator } from "../src/index.js";
import type { SolveIssueMeta } from "../src/roles.js";
const DEFAULT_PHASES: PlannerMeta["phases"] = [
{ name: "phase-a", description: "Do the work", acceptance: "Done" },
@@ -33,6 +32,7 @@ function makeCtx(
): ThreadContext<SolveIssueMeta> {
return {
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "" },
start: makeStart(maxRounds),
steps,
};
@@ -76,6 +76,11 @@ function committerStep(): RoleStep<SolveIssueMeta> {
};
}
const stubExtract = {
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
dryRun: true,
} as const;
describe("solveIssueModerator", () => {
test("routes planner → coder → reviewer → committer → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
@@ -131,57 +136,53 @@ describe("solveIssueModerator", () => {
});
});
describe("createSolveIssueRoles", () => {
test("returns all four role callables", async () => {
const agent = async () => '{"phases":[{"name":"x","description":"d","acceptance":"a"}]}';
const roles = createSolveIssueRoles({
agent,
workdir: "/tmp/repo",
extract: null,
describe("createSolveIssueRun", () => {
test("dry-run extraction yields role dryRunMeta for planner", async () => {
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract);
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
);
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
throw new Error("expected yield");
}
expect(first.value.role).toBe("planner");
expect(first.value.meta).toEqual(plannerRole.dryRunMeta);
});
expect(typeof roles.planner.run).toBe("function");
expect(typeof roles.coder.run).toBe("function");
expect(typeof roles.reviewer.run).toBe("function");
expect(typeof roles.committer.run).toBe("function");
const ctx = makeCtx(10, []);
const plannerOut = await roles.planner.run(ctx as unknown as ThreadContext);
expect(plannerOut.meta.phases).toEqual([
{ name: "phase-1", description: "placeholder", acceptance: "placeholder" },
]);
});
test("per-role agents override default agent", async () => {
test("per-role agent overrides default", async () => {
const calls: string[] = [];
const tag =
(label: string): AgentFn =>
async () => {
calls.push(label);
const run = createSolveIssueRun(
{
agent: async () => {
calls.push("default");
return "";
};
const roles = createSolveIssueRoles({
agent: tag("default"),
agents: {
planner: tag("planner"),
coder: tag("coder"),
},
workdir: "/tmp/repo",
extract: null,
});
const ctx = makeCtx(10, []);
await roles.planner.run(ctx as unknown as ThreadContext);
overrides: {
planner: async () => {
calls.push("planner");
return "";
},
coder: async () => {
calls.push("coder");
return "";
},
},
},
stubExtract,
);
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
);
await gen.next();
expect(calls).toEqual(["planner"]);
calls.length = 0;
await roles.coder.run(ctx as unknown as ThreadContext);
await gen.next();
expect(calls).toEqual(["coder"]);
calls.length = 0;
await roles.reviewer.run(ctx as unknown as ThreadContext);
expect(calls).toEqual(["default"]);
});
});
@@ -10,11 +10,9 @@
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-cursor": "workspace:*",
"@uncaged/workflow-role-committer": "workspace:*",
"@uncaged/workflow-role-coder": "workspace:*",
"@uncaged/workflow-role-planner": "workspace:*",
"@uncaged/workflow-role-reviewer": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*"
"@uncaged/workflow-role-reviewer": "workspace:*"
}
}
@@ -1,22 +1,12 @@
import { buildDescriptor } from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.js";
import {
createSolveIssueRoles,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueRolesConfig,
} from "./roles.js";
const BUILD_DESCRIPTOR_CONFIG: SolveIssueRolesConfig = {
agent: async () => "",
workdir: "/tmp/uncaged-workflow-descriptor-stub",
extract: null,
};
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
export function buildSolveIssueDescriptor() {
return buildDescriptor({
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: createSolveIssueRoles(BUILD_DESCRIPTOR_CONFIG),
roles: solveIssueRoles,
moderator: solveIssueModerator,
});
}
@@ -1,49 +1,50 @@
import { createRoleModerator, type WorkflowDefinition, type WorkflowFn } from "@uncaged/workflow";
import {
type AgentBinding,
createWorkflow,
type ExtractConfig,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.js";
import {
createSolveIssueRoles,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
type SolveIssueRolesConfig,
} from "./roles.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
export {
type CoderMeta,
coderMetaSchema,
createCoderRole,
coderRole,
} from "@uncaged/workflow-role-coder";
export {
createPlannerRole,
type CommitterMeta,
committerMetaSchema,
committerRole,
} from "@uncaged/workflow-role-committer";
export {
type PlannerMeta,
phaseSchema,
plannerMetaSchema,
plannerRole,
} from "@uncaged/workflow-role-planner";
export {
type ReviewerMeta,
reviewerMetaSchema,
reviewerRole,
} from "@uncaged/workflow-role-reviewer";
export { buildSolveIssueDescriptor } from "./descriptor.js";
export { solveIssueModerator } from "./moderator.js";
export {
createSolveIssueRoles,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
type SolveIssueRoles,
type SolveIssueRolesConfig,
solveIssueRoles,
} from "./roles.js";
export function createSolveIssueWorkflowDefinition(
config: SolveIssueRolesConfig,
): WorkflowDefinition<SolveIssueMeta> {
return {
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: createSolveIssueRoles(config),
roles: solveIssueRoles,
moderator: solveIssueModerator,
};
}
};
/**
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
*/
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
return createRoleModerator(createSolveIssueWorkflowDefinition(config));
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn {
return createWorkflow(solveIssueWorkflowDefinition, binding, extract);
}
@@ -1,27 +1,8 @@
import type { AgentFn, RoleDefinition } from "@uncaged/workflow";
import { type CoderMeta, coderMetaSchema, createCoderRole } from "@uncaged/workflow-role-coder";
import {
type CommitterMeta,
committerMetaSchema,
createCommitterRole,
} from "@uncaged/workflow-role-committer";
import {
createPlannerRole,
type PlannerMeta,
plannerMetaSchema,
} from "@uncaged/workflow-role-planner";
import {
createReviewerRole,
type ReviewerMeta,
reviewerMetaSchema,
} from "@uncaged/workflow-role-reviewer";
import type { LlmProvider } from "@uncaged/workflow-util-role";
const DRY_RUN_PROVIDER: LlmProvider = {
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "template-dry-run",
};
import type { RoleDefinition } from "@uncaged/workflow";
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
"Phased plan, incremental implementation per phase, review, and commit to resolve an issue end-to-end (planner → coder [repeat per phase] → reviewer → committer).";
@@ -33,142 +14,13 @@ export type SolveIssueMeta = {
committer: CommitterMeta;
};
const PLANNER_DRY_RUN_META: PlannerMeta = {
phases: [
{
name: "phase-1",
description: "placeholder",
acceptance: "placeholder",
},
],
};
const CODER_DRY_RUN_META: CoderMeta = {
completedPhase: "phase-1",
filesChanged: [],
summary: "",
};
const REVIEWER_DRY_RUN_META: ReviewerMeta = {
status: "approved",
};
const COMMITTER_DRY_RUN_META: CommitterMeta = {
status: "committed",
branch: "dry-run/placeholder",
commitSha: "0000000",
};
/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */
export type SolveIssueRolesConfig = {
agent: AgentFn;
agents?: Partial<{
planner: AgentFn;
coder: AgentFn;
reviewer: AgentFn;
committer: AgentFn;
}>;
workdir: string;
extract: { provider: LlmProvider; dryRun: boolean | null } | null;
};
function resolveRoleAgent(
config: SolveIssueRolesConfig,
role: keyof NonNullable<SolveIssueRolesConfig["agents"]>,
): AgentFn {
return config.agents?.[role] ?? config.agent;
}
function resolveExtract(config: SolveIssueRolesConfig): {
provider: LlmProvider;
dryRun: boolean | null;
} {
if (config.extract === null) {
return { provider: DRY_RUN_PROVIDER, dryRun: true };
}
return config.extract;
}
export type SolveIssueRoles = {
planner: RoleDefinition<PlannerMeta>;
coder: RoleDefinition<CoderMeta>;
reviewer: RoleDefinition<ReviewerMeta>;
committer: RoleDefinition<CommitterMeta>;
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
};
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
const extract = resolveExtract(config);
const reviewerConfig = {
cwd: config.workdir,
};
const committerConfig = {
cwd: config.workdir,
};
const coderConfig = {
cwd: config.workdir,
};
const plannerAgent = resolveRoleAgent(config, "planner");
const coderAgent = resolveRoleAgent(config, "coder");
const reviewerAgent = resolveRoleAgent(config, "reviewer");
const committerAgent = resolveRoleAgent(config, "committer");
const plannerRun = createPlannerRole(plannerAgent, {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: PLANNER_DRY_RUN_META,
});
const coderRun = createCoderRole(
coderAgent,
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: CODER_DRY_RUN_META,
},
coderConfig,
);
const reviewerRun = createReviewerRole(
reviewerAgent,
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: REVIEWER_DRY_RUN_META,
},
reviewerConfig,
);
const committerRun = createCommitterRole(
committerAgent,
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: COMMITTER_DRY_RUN_META,
},
committerConfig,
);
return {
planner: {
description: "Analyzes the issue and emits ordered implementation phases.",
run: plannerRun,
schema: plannerMetaSchema,
},
coder: {
description: "Implements the next incomplete phase and reports completedPhase.",
run: coderRun,
schema: coderMetaSchema,
},
reviewer: {
description: "Runs git diff checks and sets approved when the change is ready.",
run: reviewerRun,
schema: reviewerMetaSchema,
},
committer: {
description: "Creates branch, commits, and pushes when review passes.",
run: committerRun,
schema: committerMetaSchema,
},
};
}
export const solveIssueRoles: SolveIssueRoles = {
planner: plannerRole,
coder: coderRole,
reviewer: reviewerRole,
committer: committerRole,
};
@@ -11,7 +11,6 @@
{ "path": "../workflow-role-coder" },
{ "path": "../workflow-role-committer" },
{ "path": "../workflow-role-planner" },
{ "path": "../workflow-role-reviewer" },
{ "path": "../workflow-util-role" }
{ "path": "../workflow-role-reviewer" }
]
}
@@ -18,6 +18,7 @@ describe("buildAgentPrompt", () => {
start: startTask("fix the bug"),
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "" },
};
const text = buildAgentPrompt("You are an agent.", ctx);
expect(text).toContain("You are an agent.");
@@ -30,6 +31,7 @@ describe("buildAgentPrompt", () => {
const ctx: ThreadContext = {
start: startTask("user task"),
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "" },
steps: [
{
role: "coder",
@@ -53,6 +55,7 @@ describe("buildAgentPrompt", () => {
const ctx: ThreadContext = {
start: startTask("first message full: task content here"),
threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "" },
steps: [
{
role: "planner",
@@ -85,6 +88,7 @@ describe("buildAgentPrompt", () => {
const ctx: ThreadContext = {
start: startTask("start"),
threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "" },
steps: [
{
role: "a",
@@ -1,140 +0,0 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import * as extractMetaModule from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
import { createRole } from "../src/create-role.js";
const provider = {
baseUrl: "https://example.com/v1",
apiKey: "k",
model: "m",
};
function toolCallResponse(argsJson: string): Response {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: argsJson,
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
function makeCtx(): ThreadContext {
return {
start: {
role: START,
content: "",
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
threadId: "01TEST000000000000000000TR",
};
}
describe("createRole", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
mock.restore();
});
test("runs AgentFn then structured extract", async () => {
globalThis.fetch = (() =>
Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })))) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const agent: AgentFn = async (_ctx, prompt) => prompt;
const role = createRole({
name: "test",
schema,
systemPrompt: "hello",
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
const out = await role(makeCtx());
expect(out.content).toBe("hello");
expect(out.meta).toEqual({ n: 3 });
});
test("passes ThreadContext to AgentFn", async () => {
globalThis.fetch = (() =>
Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })))) as unknown as typeof fetch;
const seen: ThreadContext[] = [];
const agent: AgentFn = async (ctx, _prompt) => {
seen.push(ctx);
return "x";
};
const role = createRole({
name: "test",
schema: z.object({ n: z.number() }),
systemPrompt: "p",
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
await role(makeCtx());
expect(seen).toHaveLength(1);
expect(seen[0].steps).toEqual([]);
});
test("extract dryRun null runs live extract path", async () => {
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const agent: AgentFn = async () => "raw";
const role = createRole({
name: "r1",
schema: z.object({ n: z.number() }),
systemPrompt: "p",
agent,
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
});
await role(makeCtx());
expect(spy).toHaveBeenCalledWith(
"r1",
"raw",
expect.anything(),
expect.objectContaining({ provider, dryRun: false, dryRunMeta: { n: 0 } }),
);
});
test("extract.dryRun true uses structured extract dry-run", async () => {
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
const agent: AgentFn = async () => "raw";
const role = createRole({
name: "r2",
schema: z.object({ n: z.number() }),
systemPrompt: "p",
agent,
extract: { provider, dryRun: true, dryRunMeta: { n: 0 } },
});
await role(makeCtx());
expect(spy).toHaveBeenCalledWith(
"r2",
"raw",
expect.anything(),
expect.objectContaining({ dryRun: true, dryRunMeta: { n: 0 } }),
);
});
});
@@ -1,101 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { Role, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import { decorateRole, onFail, withDryRun } from "../src/decorators.js";
type TestMeta = Record<string, unknown> & { ok: boolean };
function fakeCtx(): ThreadContext {
return {
start: {
role: START,
content: "",
meta: {
maxRounds: 10,
},
timestamp: Date.now(),
},
steps: [],
threadId: "01TEST000000000000000000TR",
};
}
const successRole: Role<TestMeta> = async () => ({
content: "done",
meta: { ok: true },
});
const failRole: Role<TestMeta> = async () => {
throw new Error("boom");
};
const failNonErrorRole: Role<TestMeta> = async () => {
throw "string error";
};
describe("withDryRun", () => {
test("short-circuits on dry-run", async () => {
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: true });
const role = dec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("[dry-run] test skipped");
expect(result.meta).toEqual({ ok: true });
});
test("delegates when not dry-run", async () => {
const innerDec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: false });
const role = innerDec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("done");
expect(result.meta).toEqual({ ok: true });
});
});
describe("onFail", () => {
test("passes through on success", async () => {
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
const role = dec(successRole);
const result = await role(fakeCtx());
expect(result.content).toBe("done");
expect(result.meta).toEqual({ ok: true });
});
test("catches Error and returns structured failure", async () => {
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
const role = dec(failRole);
const result = await role(fakeCtx());
expect(result.content).toBe("test failed: boom");
expect(result.meta).toEqual({ ok: false });
});
test("catches non-Error throws", async () => {
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
const role = dec(failNonErrorRole);
const result = await role(fakeCtx());
expect(result.content).toBe("test failed: string error");
expect(result.meta).toEqual({ ok: false });
});
});
describe("decorateRole", () => {
test("applies decorators left-to-right", async () => {
const role = decorateRole(failRole, [
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: false }),
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
]);
const result = await role(fakeCtx());
expect(result.content).toBe("x failed: boom");
expect(result.meta).toEqual({ ok: false });
});
test("dry-run short-circuits before onFail", async () => {
const role = decorateRole(failRole, [
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: true }),
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
]);
const result = await role(fakeCtx());
expect(result.content).toBe("[dry-run] x skipped");
expect(result.meta).toEqual({ ok: true });
});
});
@@ -1,102 +0,0 @@
import { describe, expect, test } from "bun:test";
import * as z from "zod/v4";
import { extractMetaOrThrow } from "../src/extract-meta.js";
const provider = {
baseUrl: "https://example.com/v1",
apiKey: "k",
model: "m",
};
describe("extractMetaOrThrow", () => {
const originalFetch = globalThis.fetch;
test("dryRun returns dryRunMeta without calling fetch", async () => {
let calls = 0;
globalThis.fetch = (() => {
calls += 1;
return Promise.resolve(new Response("{}", { status: 200 }));
}) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const out = await extractMetaOrThrow("r", "raw", schema, {
provider,
dryRun: true,
dryRunMeta: { n: 7 },
});
globalThis.fetch = originalFetch;
expect(calls).toBe(0);
expect(out).toEqual({ n: 7 });
});
test("throws when extraction fails after retry", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{ function: { name: "extract", arguments: JSON.stringify({ n: "bad" }) } },
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
await expect(
extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false, dryRunMeta: { n: 0 } }),
).rejects.toThrow(/structured extraction failed after retry/);
globalThis.fetch = originalFetch;
});
test("returns validated meta on successful tool call", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: JSON.stringify({ branch: "feat/x", message: "feat: y" }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)) as unknown as typeof fetch;
const schema = z.object({
branch: z.string(),
message: z.string(),
});
const out = await extractMetaOrThrow("committer-plan", "plan text", schema, {
provider,
dryRun: false,
dryRunMeta: { branch: "", message: "" },
});
globalThis.fetch = originalFetch;
expect(out).toEqual({ branch: "feat/x", message: "feat: y" });
});
});
@@ -1,146 +0,0 @@
import { describe, expect, test } from "bun:test";
import * as z from "zod/v4";
import { llmExtract } from "../src/llm-extract.js";
describe("llmExtract", () => {
const originalFetch = globalThis.fetch;
test("parses tool call arguments and validates with the zod schema", async () => {
const schema = z
.object({
name: z.string(),
description: z.string(),
})
.describe("Extract sense metadata from plan");
let capturedUrl: string | null = null;
let capturedInit: RequestInit | null = null;
globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => {
capturedUrl = typeof input === "string" ? input : input.toString();
capturedInit = init ?? null;
return Promise.resolve(
new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
function: {
name: "extract",
arguments: JSON.stringify({
name: "cpu-usage",
description: "CPU load",
}),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
}) as unknown as typeof fetch;
const result = await llmExtract({
text: "some plan",
schema,
provider: {
baseUrl: "https://example.com/v1",
apiKey: "k",
model: "m",
},
dryRun: false,
dryRunMeta: { name: "", description: "" },
});
globalThis.fetch = originalFetch;
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" });
expect(capturedUrl!).toBe("https://example.com/v1/chat/completions");
expect(capturedInit!.method).toBe("POST");
expect(capturedInit!.headers).toMatchObject({
Authorization: "Bearer k",
"Content-Type": "application/json",
});
const body = JSON.parse(capturedInit!.body as string) as {
model: string;
tool_choice: { function: { name: string } };
};
expect(body.model).toBe("m");
expect(body.tool_choice.function.name).toBeDefined();
});
test("returns schema_validation_failed when arguments do not match the schema", async () => {
const schema = z.object({ n: z.number() });
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{ function: { name: "extract", arguments: JSON.stringify({ n: "oops" }) } },
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)) as unknown as typeof fetch;
const result = await llmExtract({
text: "x",
schema,
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
dryRun: false,
dryRunMeta: { n: 0 },
});
globalThis.fetch = originalFetch;
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.kind).toBe("schema_validation_failed");
});
test("dryRun skips fetch and returns dryRunMeta", async () => {
let calls = 0;
globalThis.fetch = (() => {
calls += 1;
return Promise.resolve(new Response("{}", { status: 200 }));
}) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const result = await llmExtract({
text: "ignored",
schema,
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
dryRun: true,
dryRunMeta: { n: 42 },
});
globalThis.fetch = originalFetch;
expect(calls).toBe(0);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({ n: 42 });
});
});
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@uncaged/workflow-util-role",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -1,34 +0,0 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import type * as z from "zod/v4";
import { extractMetaOrThrow } from "./extract-meta.js";
import type { LlmProvider } from "./types.js";
export type CreateRoleArgs<M extends Record<string, unknown>> = {
name: string;
schema: z.ZodType<M>;
systemPrompt: string;
agent: AgentFn;
extract: {
provider: LlmProvider;
/** When `true`, structured extract returns `dryRunMeta`. When `null`, live API extract. */
dryRun: boolean | null;
dryRunMeta: M;
};
};
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
/** Builds a {@link Role} from an {@link AgentFn}, system prompt, Zod meta schema, and extract wiring. */
export function createRole<M extends Record<string, unknown>>(args: CreateRoleArgs<M>): Role<M> {
return async (ctx: ThreadContext) => {
const raw = await args.agent(ctx, args.systemPrompt);
const meta = await extractMetaOrThrow(args.name, raw, args.schema, {
provider: args.extract.provider,
dryRun: resolveExtractDryRun(args.extract.dryRun),
dryRunMeta: args.extract.dryRunMeta,
});
return { content: raw, meta };
};
}
@@ -1,63 +0,0 @@
import type { Role, ThreadContext } from "@uncaged/workflow";
/** A role decorator: takes a role, returns an enhanced role. */
export type RoleDecorator<M extends Record<string, unknown>> = (role: Role<M>) => Role<M>;
/**
* Apply an ordered list of decorators to a role.
* Decorators are applied left-to-right (first in list wraps innermost).
*/
export function decorateRole<M extends Record<string, unknown>>(
role: Role<M>,
decorators: RoleDecorator<M>[],
): Role<M> {
return decorators.reduce((r, dec) => dec(r), role);
}
export type WithDryRunOptions<M extends Record<string, unknown>> = {
/** Used in skip message (e.g. "committer", "publish"). */
label: string;
/** Meta returned when dry-run skips execution. */
meta: M;
/** Adapter-level dry-run flag (e.g. from extract / wiring config). */
dryRun: boolean;
};
/** Short-circuits with a stable result when `dryRun` is true. */
export function withDryRun<M extends Record<string, unknown>>(
opts: WithDryRunOptions<M>,
): RoleDecorator<M> {
return (role) => async (ctx: ThreadContext) => {
if (opts.dryRun) {
return {
content: `[dry-run] ${opts.label} skipped`,
meta: opts.meta,
};
}
return role(ctx);
};
}
export type OnFailOptions<M extends Record<string, unknown>> = {
/** Used in failure message (e.g. "committer", "publish"). */
label: string;
/** Meta returned when the inner role throws. */
meta: M;
};
/** Catches thrown errors and converts them into a structured {@link Role} result instead of propagating. */
export function onFail<M extends Record<string, unknown>>(
opts: OnFailOptions<M>,
): RoleDecorator<M> {
return (role) => async (ctx: ThreadContext) => {
try {
return await role(ctx);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
content: `${opts.label} failed: ${msg}`,
meta: opts.meta,
};
}
};
}
-18
View File
@@ -1,18 +0,0 @@
export { type CreateRoleArgs, createRole } from "./create-role.js";
export {
decorateRole,
type OnFailOptions,
onFail,
type RoleDecorator,
type WithDryRunOptions,
withDryRun,
} from "./decorators.js";
export { extractMetaOrThrow } from "./extract-meta.js";
export {
type LlmError,
type LlmExtractArgs,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
} from "./llm-extract.js";
export type { LlmMessage, LlmProvider, MetaExtractConfig } from "./types.js";
-15
View File
@@ -1,15 +0,0 @@
import type * as z from "zod/v4";
export type LlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
/** Pairs an OpenAI-compatible provider with the Zod meta schema used for structured extraction. */
export type MetaExtractConfig<T> = {
provider: LlmProvider;
schema: z.ZodType<T>;
};
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
}
@@ -19,8 +19,9 @@ describe("buildDescriptor", () => {
roles: {
analyst: {
description: "Analyzes input",
systemPrompt: "You are an analyst.",
schema,
run: async () => ({ content: "", meta: { title: "", count: 0 } }),
dryRunMeta: { title: "", count: 0 },
},
},
moderator: () => END,
+22 -11
View File
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
import { createRoleModerator } from "../src/create-role-moderator.js";
import { createWorkflow } from "../src/create-workflow.js";
import { executeThread } from "../src/engine.js";
import { createLogger } from "../src/logger.js";
import { END } from "../src/types.js";
@@ -23,23 +23,25 @@ type DemoMeta = {
coder: z.infer<typeof coderMetaSchema>;
};
const demoWorkflow = createRoleModerator<DemoMeta>({
const demoExtract = {
provider: { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" },
dryRun: true,
} as const;
const demoWorkflow = createWorkflow<DemoMeta>(
{
roles: {
planner: {
description: "Demo planner",
systemPrompt: "You are a planner.",
schema: plannerMetaSchema,
run: async () => ({
content: "plan-body",
meta: { plan: "do-it", files: ["a.ts"] },
}),
dryRunMeta: { plan: "do-it", files: ["a.ts"] },
},
coder: {
description: "Demo coder",
systemPrompt: "You are a coder.",
schema: coderMetaSchema,
run: async () => ({
content: "code-body",
meta: { diff: "+ok" },
}),
dryRunMeta: { diff: "+ok" },
},
},
moderator: (ctx) => {
@@ -51,7 +53,16 @@ const demoWorkflow = createRoleModerator<DemoMeta>({
}
return END;
},
});
},
{
agent: async () => "unused",
overrides: {
planner: async () => "plan-body",
coder: async () => "code-body",
},
},
demoExtract,
);
describe("executeThread", () => {
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => {
@@ -1,91 +0,0 @@
import {
END,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadContext,
type ThreadInput,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
type WorkflowResult,
} from "./types.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
return next !== END;
}
/**
* Role + Moderator pattern as an optional helper: returns a {@link WorkflowFn} that runs the
* moderator loop and yields each {@link RoleOutput}. Assign with `export const run = createRoleModerator(...)`.
*/
export function createRoleModerator<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
): WorkflowFn {
return async function* roleModeratorWorkflow(
input: ThreadInput,
options: WorkflowFnOptions,
): AsyncGenerator<RoleOutput, WorkflowResult> {
const nowMs = Date.now();
const start: ThreadContext<M>["start"] = {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
timestamp: nowMs,
};
const baseTs = Date.now();
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
role: out.role,
content: out.content,
meta: out.meta,
timestamp: baseTs + i,
})) as RoleStep<M>[];
while (true) {
if (steps.length >= options.maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
};
}
const ctx: ThreadContext<M> = {
threadId: options.threadId,
start,
steps,
};
const next = def.moderator(ctx);
if (!isRoleNext(next)) {
return { returnCode: 0, summary: "completed: moderator returned END" };
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { returnCode: 1, summary: `unknown role: ${next}` };
}
const result = await roleDef.run(ctx as unknown as ThreadContext);
const ts = Date.now();
const step = {
role: next,
content: result.content,
meta: result.meta,
timestamp: ts,
} as RoleStep<M>;
yield {
role: step.role,
content: step.content,
meta: step.meta,
};
steps = [...steps, step];
}
};
}
+136
View File
@@ -0,0 +1,136 @@
import { extractMetaOrThrow } from "./extract-meta.js";
import {
type AgentBinding,
END,
type ExtractConfig,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadContext,
type ThreadInput,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
type WorkflowResult,
} from "./types.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
return next !== END;
}
function moderatorThreadContext<M extends RoleMeta>(params: {
threadId: string;
start: ThreadContext<M>["start"];
steps: RoleStep<M>[];
roles: Pick<WorkflowDefinition<M>, "roles">["roles"];
}): ThreadContext<M> {
const { threadId, start, steps, roles } = params;
const last = steps[steps.length - 1];
if (last === undefined) {
return {
threadId,
currentRole: { name: START, systemPrompt: "" },
start,
steps,
};
}
const roleName = last.role as keyof M & string;
const roleDef = roles[roleName];
const systemPrompt = roleDef !== undefined ? roleDef.systemPrompt : "";
return {
threadId,
currentRole: { name: roleName, systemPrompt },
start,
steps,
};
}
/**
* Binds pure role definitions + moderator to runtime agents and structured extraction.
* Assign with `export const run = createWorkflow(def, binding, extract)`.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
extract: ExtractConfig,
): WorkflowFn {
return async function* workflowLoop(
input: ThreadInput,
options: WorkflowFnOptions,
): AsyncGenerator<RoleOutput, WorkflowResult> {
const nowMs = Date.now();
const start: ThreadContext<M>["start"] = {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
timestamp: nowMs,
};
const baseTs = Date.now();
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
role: out.role,
content: out.content,
meta: out.meta,
timestamp: baseTs + i,
})) as RoleStep<M>[];
while (true) {
if (steps.length >= options.maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
};
}
const modCtx = moderatorThreadContext({
threadId: options.threadId,
start,
steps,
roles: def.roles,
});
const next = def.moderator(modCtx);
if (!isRoleNext(next)) {
return { returnCode: 0, summary: "completed: moderator returned END" };
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { returnCode: 1, summary: `unknown role: ${next}` };
}
const ctx: ThreadContext<M> = {
threadId: options.threadId,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
start,
steps,
};
const agent = binding.overrides?.[next] ?? binding.agent;
const raw = await agent(ctx as unknown as ThreadContext);
const meta = await extractMetaOrThrow(next, raw, roleDef.schema, {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: roleDef.dryRunMeta,
});
const ts = Date.now();
const step = {
role: next,
content: raw,
meta,
timestamp: ts,
} as RoleStep<M>;
yield { role: step.role, content: step.content, meta: step.meta };
steps = [...steps, step];
}
};
}
@@ -1,4 +1,5 @@
import type * as z from "zod/v4";
import { llmExtractWithRetry } from "./llm-extract.js";
import type { LlmProvider } from "./types.js";
+11 -3
View File
@@ -7,7 +7,7 @@ export {
} from "./base32.js";
export { buildDescriptor } from "./build-descriptor.js";
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
export { createRoleModerator } from "./create-role-moderator.js";
export { createWorkflow } from "./create-workflow.js";
export {
type ExecuteThreadIo,
type ExecuteThreadOptions,
@@ -15,6 +15,7 @@ export {
type PrefilledDiskStep,
} from "./engine.js";
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
export { extractMetaOrThrow } from "./extract-meta.js";
export {
buildForkPlan,
type ForkHistoricalStep,
@@ -25,6 +26,12 @@ export {
} from "./fork-thread.js";
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
export { hashWorkflowBundleBytes } from "./hash.js";
export {
type LlmError,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
} from "./llm-extract.js";
export {
type CreateLoggerOptions,
createLogger,
@@ -50,14 +57,15 @@ export { err, ok, type Result } from "./result.js";
export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
export {
type AgentBinding,
type AgentFn,
END,
type ExtractConfig,
type LlmProvider,
type Moderator,
type Role,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleResult,
type RoleStep,
START,
type StartStep,
@@ -1,5 +1,6 @@
import { err, ok, type Result } from "@uncaged/workflow";
import * as z from "zod/v4";
import { err, ok, type Result } from "./result.js";
import type { LlmProvider } from "./types.js";
export type LlmExtractArgs<T> = {
+31 -24
View File
@@ -7,6 +7,13 @@ export const END = "__end__" as const;
/** Maps role names → their meta types. Single generic drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>;
/** OpenAI-compatible LLM endpoint used for structured meta extraction. */
export type LlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
export type RoleOutput = {
role: string;
@@ -39,12 +46,6 @@ export type WorkflowFn = (
options: WorkflowFnOptions,
) => AsyncGenerator<RoleOutput, WorkflowResult>;
/** Typed output of a Role execution. */
export type RoleResult<Meta extends Record<string, unknown>> = {
content: string;
meta: Meta;
};
/** Engine start frame: initial prompt + thread identity. */
export type StartStep = {
role: typeof START;
@@ -58,33 +59,39 @@ export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
}[keyof M & string];
/** Thread-scoped context passed to roles and moderator. */
/** Thread-scoped context passed to agents and moderator. */
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
currentRole: {
name: string;
systemPrompt: string;
};
start: StartStep;
steps: RoleStep<M>[];
};
/**
* A Role — receives full thread context, returns typed content + meta.
* Implementation can be an agent, LLM call, script, HTTP request, etc.
*/
export type Role<Meta extends Record<string, unknown>> = (
ctx: ThreadContext,
) => Promise<RoleResult<Meta>>;
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
export type AgentFn = (ctx: ThreadContext) => Promise<string>;
/** Role wiring: runtime {@link Role}, JSON Schema for `meta`, and human-readable description. */
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
run: Role<Meta>;
schema: z.ZodType<Meta>;
/** Runtime agent assignment (optional per-role overrides). */
export type AgentBinding = {
agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>;
};
/**
* An Agent — raw string output interface for LLM/CLI adapters.
* Structured meta is extracted by the role's extract layer.
*/
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
/** Structured extraction settings for the workflow engine. */
export type ExtractConfig = {
provider: LlmProvider;
dryRun: boolean;
};
/** Role wiring: prompts, schema, dry-run meta, and human-readable description. */
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
schema: z.ZodType<Meta>;
dryRunMeta: Meta;
};
/**
* The Moderator — a pure routing function.
-1
View File
@@ -18,7 +18,6 @@
},
"references": [
{ "path": "packages/workflow" },
{ "path": "packages/workflow-util-role" },
{ "path": "packages/workflow-agent-llm" },
{ "path": "packages/workflow-role-committer" },
{ "path": "packages/workflow-role-coder" },