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
+19 -10
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"; import * as z from "zod/v4";
type Roles = { type Roles = {
@@ -25,16 +25,25 @@ export const descriptor = {
const greeter: RoleDefinition<Roles["greeter"]> = { const greeter: RoleDefinition<Roles["greeter"]> = {
description: "Generates a greeting", description: "Generates a greeting",
systemPrompt: "You greet the user briefly.",
schema: greeterMetaSchema, schema: greeterMetaSchema,
run: async (ctx) => ({ dryRunMeta: { greeting: "Hello!" },
content: `Hello, ${ctx.start.content}`,
meta: { greeting: "Hello!" },
}),
}; };
export const run = createRoleModerator<Roles>({ const extract = {
roles: { greeter }, provider: { baseUrl: "http://127.0.0.1:9", apiKey: "", model: "" },
moderator(ctx) { dryRun: true,
return ctx.steps.length === 0 ? "greeter" : END; } 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 modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout; const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => { return async (ctx) => {
const fullPrompt = buildAgentPrompt(systemPrompt, ctx); const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
const args = [ const args = [
"-p", "-p",
fullPrompt, fullPrompt,
+2 -2
View File
@@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
const timeoutMs = config.timeout; const timeoutMs = config.timeout;
return async (ctx, systemPrompt) => { return async (ctx) => {
const fullPrompt = buildAgentPrompt(systemPrompt, ctx); const fullPrompt = buildAgentPrompt(ctx.currentRole.systemPrompt, ctx);
const args = [ const args = [
"chat", "chat",
"-q", "-q",
@@ -13,6 +13,7 @@ function makeCtx(userContent: string): ThreadContext {
}, },
steps: [], steps: [],
threadId: "01TEST000000000000000000TR", 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 provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider); const adapter = createLlmAdapter(provider);
const out = await adapter(makeCtx("trigger text"), "system instructions"); const out = await adapter(makeCtx("trigger text"));
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
@@ -49,7 +50,7 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider); const adapter = createLlmAdapter(provider);
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:"); await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
}); });
@@ -59,7 +60,7 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" }; const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider); const adapter = createLlmAdapter(provider);
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow(); await expect(adapter(makeCtx("hi"))).rejects.toThrow();
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
}); });
}); });
+1 -6
View File
@@ -4,16 +4,11 @@
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": { "scripts": {
"build": "echo 'TODO'", "build": "echo 'TODO'",
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*"
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0"
} }
} }
@@ -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 = export type LlmChatError =
| { kind: "http_error"; status: number; body: string } | { kind: "http_error"; status: number; body: string }
@@ -89,13 +97,13 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value); 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 { export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: ThreadContext, systemPrompt: string) => { return async (ctx: ThreadContext) => {
const result = await chatCompletionText({ const result = await chatCompletionText({
provider, provider,
messages: [ messages: [
{ role: "system", content: systemPrompt }, { role: "system", content: ctx.currentRole.systemPrompt },
{ role: "user", content: ctx.start.content }, { role: "user", content: ctx.start.content },
], ],
}); });
+4 -18
View File
@@ -1,20 +1,6 @@
export { export {
type CreateRoleArgs, chatCompletionText,
createRole, createLlmAdapter,
decorateRole, type LlmChatError,
extractMetaOrThrow,
type LlmError,
type LlmExtractArgs,
type LlmMessage, type LlmMessage,
type LlmProvider, } from "./create-llm-adapter.js";
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";
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }] "references": [{ "path": "../workflow" }]
} }
@@ -10,8 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
} }
+14 -40
View File
@@ -1,6 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const coderMetaSchema = z.object({ export const coderMetaSchema = z.object({
@@ -11,41 +9,17 @@ export const coderMetaSchema = z.object({
export type CoderMeta = z.infer<typeof coderMetaSchema>; export type CoderMeta = z.infer<typeof coderMetaSchema>;
export type CoderConfig = { const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
cwd: string; Report which phase you completed. List the files you changed and summarize what you did.`;
export const coderRole: RoleDefinition<CoderMeta> = {
description:
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: CODER_SYSTEM,
schema: coderMetaSchema,
dryRunMeta: {
completedPhase: "phase-1",
filesChanged: [],
summary: "",
},
}; };
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",
schema: coderMetaSchema,
systemPrompt: coderSystemPrompt(config),
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
},
});
}
+1 -7
View File
@@ -1,7 +1 @@
export { export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
type CoderConfig,
type CoderMeta,
coderMetaSchema,
createCoderRole,
DEFAULT_CODER_CONFIG,
} from "./coder.js";
+1 -5
View File
@@ -6,9 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [ "references": [{ "path": "../workflow" }]
{ "path": "../workflow" },
{ "path": "../workflow-agent-llm" },
{ "path": "../workflow-util-role" }
]
} }
@@ -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 { committerMetaSchema, committerRole } from "../src/committer.js";
import { START } from "@uncaged/workflow";
import * as utilRole from "@uncaged/workflow-util-role";
import { createCommitterRole } from "../src/committer.js"; describe("committerRole", () => {
test("dryRunMeta validates against schema", () => {
function makeCtx(): ThreadContext { const parsed = committerMetaSchema.safeParse(committerRole.dryRunMeta);
return { expect(parsed.success).toBe(true);
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);
}); });
test("returns committed meta when extraction succeeds", async () => { test("exposes generic committer system prompt", () => {
const committed = { expect(committerRole.systemPrompt).toContain("git committer");
status: "committed" as const, expect(committerRole.systemPrompt).not.toContain("project is at");
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");
}); });
}); });
@@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
} }
@@ -1,11 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow";
import {
createRole,
decorateRole,
type LlmProvider,
onFail,
withDryRun,
} from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const committerMetaSchema = z.discriminatedUnion("status", [ 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 CommitterMeta = z.infer<typeof committerMetaSchema>;
export type CommitterConfig = { const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
cwd: string; Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
Do not attempt to fix failures yourself.`;
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",
},
}; };
export const DEFAULT_COMMITTER_CONFIG: CommitterConfig = {
cwd: ".",
};
const DRY_RUN_COMMITTED_META: CommitterMeta = {
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 { export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
type CommitterConfig,
type CommitterMeta,
committerMetaSchema,
createCommitterRole,
DEFAULT_COMMITTER_CONFIG,
} from "./committer.js";
@@ -3,13 +3,8 @@
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"composite": true, "composite": true
"types": ["bun-types"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [ "references": [{ "path": "../workflow" }]
{ "path": "../workflow" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-util-role" }
]
} }
@@ -10,8 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
} }
+1 -3
View File
@@ -1,8 +1,6 @@
export { export {
createPlannerRole,
DEFAULT_PLANNER_CONFIG,
type PlannerConfig,
type PlannerMeta, type PlannerMeta,
phaseSchema, phaseSchema,
plannerMetaSchema, plannerMetaSchema,
plannerRole,
} from "./planner.js"; } from "./planner.js";
+9 -28
View File
@@ -1,6 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const phaseSchema = z.object({ export const phaseSchema = z.object({
@@ -15,34 +13,17 @@ export const plannerMetaSchema = z.object({
export type PlannerMeta = z.infer<typeof plannerMetaSchema>; 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. 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. 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.`; 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.`;
/** export const plannerRole: RoleDefinition<PlannerMeta> = {
* Planner role: produces ordered implementation phases for the coder to execute sequentially. description: "Breaks the task into sequential phases for the coder.",
*/ systemPrompt: PLANNER_SYSTEM,
export function createPlannerRole( schema: plannerMetaSchema,
adapter: AgentFn, dryRunMeta: {
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: PlannerMeta }, phases: [{ name: "phase-1", description: "placeholder", acceptance: "placeholder" }],
_config: PlannerConfig = DEFAULT_PLANNER_CONFIG, },
): Role<PlannerMeta> { };
return createRole({
name: "planner",
schema: plannerMetaSchema,
systemPrompt: PLANNER_SYSTEM,
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
},
});
}
+1 -5
View File
@@ -6,9 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [ "references": [{ "path": "../workflow" }]
{ "path": "../workflow" },
{ "path": "../workflow-agent-llm" },
{ "path": "../workflow-util-role" }
]
} }
@@ -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 { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
import { START } from "@uncaged/workflow";
import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js"; describe("reviewerRole", () => {
test("dryRunMeta validates against schema", () => {
function toolCallResponse(argsJson: string): Response { const parsed = reviewerMetaSchema.safeParse(reviewerRole.dryRunMeta);
return new Response( expect(parsed.success).toBe(true);
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();
}); });
test("approved verdict", async () => { test("system prompt is generic (no cwd)", () => {
globalThis.fetch = (() => expect(reviewerRole.systemPrompt).toContain("code reviewer");
Promise.resolve( expect(reviewerRole.systemPrompt).not.toContain("project is at");
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");
}); });
}); });
@@ -10,8 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-llm": "workspace:*",
"@uncaged/workflow-util-role": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
} }
+1 -7
View File
@@ -1,7 +1 @@
export { export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
createReviewerRole,
DEFAULT_REVIEWER_CONFIG,
type ReviewerConfig,
type ReviewerMeta,
reviewerMetaSchema,
} from "./reviewer.js";
@@ -1,6 +1,4 @@
import type { AgentFn, Role } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const reviewerMetaSchema = z.discriminatedUnion("status", [ 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 ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
export type ReviewerConfig = { const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
cwd: string; Only reject for blocking issues. End with your verdict.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: REVIEWER_SYSTEM,
schema: reviewerMetaSchema,
dryRunMeta: { status: "approved" },
}; };
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",
schema: reviewerMetaSchema,
systemPrompt: reviewerPrompt(config),
agent: adapter,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: extract.dryRunMeta,
},
});
}
@@ -6,9 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [ "references": [{ "path": "../workflow" }]
{ "path": "../workflow" },
{ "path": "../workflow-agent-llm" },
{ "path": "../workflow-util-role" }
]
} }
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
type AgentFn,
END, END,
type RoleStep, type RoleStep,
START, START,
@@ -11,8 +10,8 @@ import {
import type { PlannerMeta } from "@uncaged/workflow-role-planner"; import type { PlannerMeta } from "@uncaged/workflow-role-planner";
import { buildSolveIssueDescriptor } from "../src/descriptor.js"; import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import { solveIssueModerator } from "../src/moderator.js"; import { createSolveIssueRun, plannerRole, solveIssueModerator } from "../src/index.js";
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js"; import type { SolveIssueMeta } from "../src/roles.js";
const DEFAULT_PHASES: PlannerMeta["phases"] = [ const DEFAULT_PHASES: PlannerMeta["phases"] = [
{ name: "phase-a", description: "Do the work", acceptance: "Done" }, { name: "phase-a", description: "Do the work", acceptance: "Done" },
@@ -33,6 +32,7 @@ function makeCtx(
): ThreadContext<SolveIssueMeta> { ): ThreadContext<SolveIssueMeta> {
return { return {
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "" },
start: makeStart(maxRounds), start: makeStart(maxRounds),
steps, 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", () => { describe("solveIssueModerator", () => {
test("routes planner → coder → reviewer → committer → END", () => { test("routes planner → coder → reviewer → committer → END", () => {
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner"); expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
@@ -131,57 +136,53 @@ describe("solveIssueModerator", () => {
}); });
}); });
describe("createSolveIssueRoles", () => { describe("createSolveIssueRun", () => {
test("returns all four role callables", async () => { test("dry-run extraction yields role dryRunMeta for planner", async () => {
const agent = async () => '{"phases":[{"name":"x","description":"d","acceptance":"a"}]}'; const run = createSolveIssueRun({ agent: async () => "" }, stubExtract);
const roles = createSolveIssueRoles({ const gen = run(
agent, { prompt: "task", steps: [] },
workdir: "/tmp/repo", { threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
extract: null, );
}); const first = await gen.next();
expect(first.done).toBe(false);
expect(typeof roles.planner.run).toBe("function"); if (first.done) {
expect(typeof roles.coder.run).toBe("function"); throw new Error("expected yield");
expect(typeof roles.reviewer.run).toBe("function"); }
expect(typeof roles.committer.run).toBe("function"); expect(first.value.role).toBe("planner");
expect(first.value.meta).toEqual(plannerRole.dryRunMeta);
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 calls: string[] = [];
const tag = const run = createSolveIssueRun(
(label: string): AgentFn => {
async () => { agent: async () => {
calls.push(label); calls.push("default");
return ""; return "";
}; },
overrides: {
const roles = createSolveIssueRoles({ planner: async () => {
agent: tag("default"), calls.push("planner");
agents: { return "";
planner: tag("planner"), },
coder: tag("coder"), coder: async () => {
calls.push("coder");
return "";
},
},
}, },
workdir: "/tmp/repo", stubExtract,
extract: null, );
}); const gen = run(
{ prompt: "task", steps: [] },
const ctx = makeCtx(10, []); { threadId: "01TEST000000000000000000TR", isDryRun: true, maxRounds: 20 },
await roles.planner.run(ctx as unknown as ThreadContext); );
await gen.next();
expect(calls).toEqual(["planner"]); expect(calls).toEqual(["planner"]);
calls.length = 0; calls.length = 0;
await roles.coder.run(ctx as unknown as ThreadContext); await gen.next();
expect(calls).toEqual(["coder"]); 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": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-agent-cursor": "workspace:*",
"@uncaged/workflow-role-committer": "workspace:*", "@uncaged/workflow-role-committer": "workspace:*",
"@uncaged/workflow-role-coder": "workspace:*", "@uncaged/workflow-role-coder": "workspace:*",
"@uncaged/workflow-role-planner": "workspace:*", "@uncaged/workflow-role-planner": "workspace:*",
"@uncaged/workflow-role-reviewer": "workspace:*", "@uncaged/workflow-role-reviewer": "workspace:*"
"@uncaged/workflow-util-role": "workspace:*"
} }
} }
@@ -1,22 +1,12 @@
import { buildDescriptor } from "@uncaged/workflow"; import { buildDescriptor } from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.js"; import { solveIssueModerator } from "./moderator.js";
import { import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
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,
};
export function buildSolveIssueDescriptor() { export function buildSolveIssueDescriptor() {
return buildDescriptor({ return buildDescriptor({
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION, description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: createSolveIssueRoles(BUILD_DESCRIPTOR_CONFIG), roles: solveIssueRoles,
moderator: solveIssueModerator, 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 { solveIssueModerator } from "./moderator.js";
import { import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
createSolveIssueRoles,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
type SolveIssueRolesConfig,
} from "./roles.js";
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
export { export {
type CoderMeta, type CoderMeta,
coderMetaSchema, coderMetaSchema,
createCoderRole, coderRole,
} from "@uncaged/workflow-role-coder"; } from "@uncaged/workflow-role-coder";
export { export {
createPlannerRole, type CommitterMeta,
committerMetaSchema,
committerRole,
} from "@uncaged/workflow-role-committer";
export {
type PlannerMeta, type PlannerMeta,
phaseSchema, phaseSchema,
plannerMetaSchema, plannerMetaSchema,
plannerRole,
} from "@uncaged/workflow-role-planner"; } from "@uncaged/workflow-role-planner";
export {
type ReviewerMeta,
reviewerMetaSchema,
reviewerRole,
} from "@uncaged/workflow-role-reviewer";
export { buildSolveIssueDescriptor } from "./descriptor.js"; export { buildSolveIssueDescriptor } from "./descriptor.js";
export { solveIssueModerator } from "./moderator.js"; export { solveIssueModerator } from "./moderator.js";
export { export {
createSolveIssueRoles,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION, SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta, type SolveIssueMeta,
type SolveIssueRoles, type SolveIssueRoles,
type SolveIssueRolesConfig, solveIssueRoles,
} from "./roles.js"; } from "./roles.js";
export function createSolveIssueWorkflowDefinition( export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
config: SolveIssueRolesConfig, description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
): WorkflowDefinition<SolveIssueMeta> { roles: solveIssueRoles,
return { moderator: solveIssueModerator,
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION, };
roles: createSolveIssueRoles(config),
moderator: solveIssueModerator,
};
}
/** export function createSolveIssueRun(binding: AgentBinding, extract: ExtractConfig): WorkflowFn {
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result return createWorkflow(solveIssueWorkflowDefinition, binding, extract);
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
*/
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
return createRoleModerator(createSolveIssueWorkflowDefinition(config));
} }
@@ -1,27 +1,8 @@
import type { AgentFn, RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow";
import { type CoderMeta, coderMetaSchema, createCoderRole } from "@uncaged/workflow-role-coder"; import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
import { import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
type CommitterMeta, import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
committerMetaSchema, import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
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",
};
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION = 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)."; "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; 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 = { export type SolveIssueRoles = {
planner: RoleDefinition<PlannerMeta>; [K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
coder: RoleDefinition<CoderMeta>;
reviewer: RoleDefinition<ReviewerMeta>;
committer: RoleDefinition<CommitterMeta>;
}; };
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles { export const solveIssueRoles: SolveIssueRoles = {
const extract = resolveExtract(config); planner: plannerRole,
const reviewerConfig = { coder: coderRole,
cwd: config.workdir, reviewer: reviewerRole,
}; committer: committerRole,
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,
},
};
}
@@ -11,7 +11,6 @@
{ "path": "../workflow-role-coder" }, { "path": "../workflow-role-coder" },
{ "path": "../workflow-role-committer" }, { "path": "../workflow-role-committer" },
{ "path": "../workflow-role-planner" }, { "path": "../workflow-role-planner" },
{ "path": "../workflow-role-reviewer" }, { "path": "../workflow-role-reviewer" }
{ "path": "../workflow-util-role" }
] ]
} }
@@ -18,6 +18,7 @@ describe("buildAgentPrompt", () => {
start: startTask("fix the bug"), start: startTask("fix the bug"),
steps: [], steps: [],
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "" },
}; };
const text = buildAgentPrompt("You are an agent.", ctx); const text = buildAgentPrompt("You are an agent.", ctx);
expect(text).toContain("You are an agent."); expect(text).toContain("You are an agent.");
@@ -30,6 +31,7 @@ describe("buildAgentPrompt", () => {
const ctx: ThreadContext = { const ctx: ThreadContext = {
start: startTask("user task"), start: startTask("user task"),
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "" },
steps: [ steps: [
{ {
role: "coder", role: "coder",
@@ -53,6 +55,7 @@ describe("buildAgentPrompt", () => {
const ctx: ThreadContext = { const ctx: ThreadContext = {
start: startTask("first message full: task content here"), start: startTask("first message full: task content here"),
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "" },
steps: [ steps: [
{ {
role: "planner", role: "planner",
@@ -85,6 +88,7 @@ describe("buildAgentPrompt", () => {
const ctx: ThreadContext = { const ctx: ThreadContext = {
start: startTask("start"), start: startTask("start"),
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "" },
steps: [ steps: [
{ {
role: "a", 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: { roles: {
analyst: { analyst: {
description: "Analyzes input", description: "Analyzes input",
systemPrompt: "You are an analyst.",
schema, schema,
run: async () => ({ content: "", meta: { title: "", count: 0 } }), dryRunMeta: { title: "", count: 0 },
}, },
}, },
moderator: () => END, moderator: () => END,
+37 -26
View File
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import * as z from "zod/v4"; 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 { executeThread } from "../src/engine.js";
import { createLogger } from "../src/logger.js"; import { createLogger } from "../src/logger.js";
import { END } from "../src/types.js"; import { END } from "../src/types.js";
@@ -23,35 +23,46 @@ type DemoMeta = {
coder: z.infer<typeof coderMetaSchema>; coder: z.infer<typeof coderMetaSchema>;
}; };
const demoWorkflow = createRoleModerator<DemoMeta>({ const demoExtract = {
roles: { provider: { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" },
planner: { dryRun: true,
description: "Demo planner", } as const;
schema: plannerMetaSchema,
run: async () => ({ const demoWorkflow = createWorkflow<DemoMeta>(
content: "plan-body", {
meta: { plan: "do-it", files: ["a.ts"] }, roles: {
}), planner: {
description: "Demo planner",
systemPrompt: "You are a planner.",
schema: plannerMetaSchema,
dryRunMeta: { plan: "do-it", files: ["a.ts"] },
},
coder: {
description: "Demo coder",
systemPrompt: "You are a coder.",
schema: coderMetaSchema,
dryRunMeta: { diff: "+ok" },
},
}, },
coder: { moderator: (ctx) => {
description: "Demo coder", if (ctx.steps.length === 0) {
schema: coderMetaSchema, return "planner";
run: async () => ({ }
content: "code-body", if (ctx.steps.length === 1) {
meta: { diff: "+ok" }, return "coder";
}), }
return END;
}, },
}, },
moderator: (ctx) => { {
if (ctx.steps.length === 0) { agent: async () => "unused",
return "planner"; overrides: {
} planner: async () => "plan-body",
if (ctx.steps.length === 1) { coder: async () => "code-body",
return "coder"; },
}
return END;
}, },
}); demoExtract,
);
describe("executeThread", () => { describe("executeThread", () => {
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => { 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 type * as z from "zod/v4";
import { llmExtractWithRetry } from "./llm-extract.js"; import { llmExtractWithRetry } from "./llm-extract.js";
import type { LlmProvider } from "./types.js"; import type { LlmProvider } from "./types.js";
+11 -3
View File
@@ -7,7 +7,7 @@ export {
} from "./base32.js"; } from "./base32.js";
export { buildDescriptor } from "./build-descriptor.js"; export { buildDescriptor } from "./build-descriptor.js";
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js"; export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
export { createRoleModerator } from "./create-role-moderator.js"; export { createWorkflow } from "./create-workflow.js";
export { export {
type ExecuteThreadIo, type ExecuteThreadIo,
type ExecuteThreadOptions, type ExecuteThreadOptions,
@@ -15,6 +15,7 @@ export {
type PrefilledDiskStep, type PrefilledDiskStep,
} from "./engine.js"; } from "./engine.js";
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js"; export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
export { extractMetaOrThrow } from "./extract-meta.js";
export { export {
buildForkPlan, buildForkPlan,
type ForkHistoricalStep, type ForkHistoricalStep,
@@ -25,6 +26,12 @@ export {
} from "./fork-thread.js"; } from "./fork-thread.js";
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
export { hashWorkflowBundleBytes } from "./hash.js"; export { hashWorkflowBundleBytes } from "./hash.js";
export {
type LlmError,
llmErrorToCause,
llmExtract,
llmExtractWithRetry,
} from "./llm-extract.js";
export { export {
type CreateLoggerOptions, type CreateLoggerOptions,
createLogger, createLogger,
@@ -50,14 +57,15 @@ export { err, ok, type Result } from "./result.js";
export { getDefaultWorkflowStorageRoot } from "./storage-root.js"; export { getDefaultWorkflowStorageRoot } from "./storage-root.js";
export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js"; export { createThreadPauseGate, type ThreadPauseGate } from "./thread-pause-gate.js";
export { export {
type AgentBinding,
type AgentFn, type AgentFn,
END, END,
type ExtractConfig,
type LlmProvider,
type Moderator, type Moderator,
type Role,
type RoleDefinition, type RoleDefinition,
type RoleMeta, type RoleMeta,
type RoleOutput, type RoleOutput,
type RoleResult,
type RoleStep, type RoleStep,
START, START,
type StartStep, type StartStep,
@@ -1,5 +1,6 @@
import { err, ok, type Result } from "@uncaged/workflow";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { err, ok, type Result } from "./result.js";
import type { LlmProvider } from "./types.js"; import type { LlmProvider } from "./types.js";
export type LlmExtractArgs<T> = { 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. */ /** Maps role names → their meta types. Single generic drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>; 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). */ /** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
export type RoleOutput = { export type RoleOutput = {
role: string; role: string;
@@ -39,12 +46,6 @@ export type WorkflowFn = (
options: WorkflowFnOptions, options: WorkflowFnOptions,
) => AsyncGenerator<RoleOutput, WorkflowResult>; ) => 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. */ /** Engine start frame: initial prompt + thread identity. */
export type StartStep = { export type StartStep = {
role: typeof START; 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 }; [K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
}[keyof M & string]; }[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> = { export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string; threadId: string;
currentRole: {
name: string;
systemPrompt: string;
};
start: StartStep; start: StartStep;
steps: RoleStep<M>[]; steps: RoleStep<M>[];
}; };
/** /** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
* A Role — receives full thread context, returns typed content + meta. export type AgentFn = (ctx: ThreadContext) => Promise<string>;
* Implementation can be an agent, LLM call, script, HTTP request, etc.
*/
export type Role<Meta extends Record<string, unknown>> = (
ctx: ThreadContext,
) => Promise<RoleResult<Meta>>;
/** Role wiring: runtime {@link Role}, JSON Schema for `meta`, and human-readable description. */ /** Runtime agent assignment (optional per-role overrides). */
export type RoleDefinition<Meta extends Record<string, unknown>> = { export type AgentBinding = {
description: string; agent: AgentFn;
run: Role<Meta>; overrides?: Partial<Record<string, AgentFn>>;
schema: z.ZodType<Meta>;
}; };
/** /** Structured extraction settings for the workflow engine. */
* An Agent — raw string output interface for LLM/CLI adapters. export type ExtractConfig = {
* Structured meta is extracted by the role's extract layer. provider: LlmProvider;
*/ dryRun: boolean;
export type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>; };
/** 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. * The Moderator — a pure routing function.
-1
View File
@@ -18,7 +18,6 @@
}, },
"references": [ "references": [
{ "path": "packages/workflow" }, { "path": "packages/workflow" },
{ "path": "packages/workflow-util-role" },
{ "path": "packages/workflow-agent-llm" }, { "path": "packages/workflow-agent-llm" },
{ "path": "packages/workflow-role-committer" }, { "path": "packages/workflow-role-committer" },
{ "path": "packages/workflow-role-coder" }, { "path": "packages/workflow-role-coder" },