refactor: unify RoleDefinition + WorkflowDefinition with description & schema

- Add RoleDefinition<Meta> = { description, run, schema } to core types
- WorkflowDefinition now carries description and RoleDefinition per role
- Add buildDescriptor() in core to derive WorkflowDescriptor from WorkflowDefinition
- Remove buildDescriptorFromRoles / RoleDescriptorInput from workflow-util-role
- Update solve-issue template, examples, and all tests

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 11:19:49 +00:00
parent 79cf97e617
commit c7b0beb6be
23 changed files with 230 additions and 198 deletions
+2
View File
@@ -0,0 +1,2 @@
[test]
pathIgnorePatterns = ["dist/**"]
+14 -5
View File
@@ -1,9 +1,14 @@
import { createRoleModerator, END, type Role } from "@uncaged/workflow";
import { createRoleModerator, END, type RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
type Roles = {
greeter: { greeting: string };
};
const greeterMetaSchema = z.object({
greeting: z.string(),
});
export const descriptor = {
description: "A simple hello world workflow",
roles: {
@@ -18,10 +23,14 @@ export const descriptor = {
},
};
const greeter: Role<Roles["greeter"]> = async (ctx) => ({
content: `Hello, ${ctx.start.content}`,
meta: { greeting: "Hello!" },
});
const greeter: RoleDefinition<Roles["greeter"]> = {
description: "Generates a greeting",
schema: greeterMetaSchema,
run: async (ctx) => ({
content: `Hello, ${ctx.start.content}`,
meta: { greeting: "Hello!" },
}),
};
export const run = createRoleModerator<Roles>({
roles: { greeter },
+2 -1
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -11,8 +11,8 @@ function makeCtx(userContent: string): ThreadContext {
meta: { maxRounds: 10 },
timestamp: 1,
},
steps: [],
threadId: "01TEST000000000000000000TR",
steps: [],
threadId: "01TEST000000000000000000TR",
};
}
-2
View File
@@ -1,5 +1,4 @@
export {
buildDescriptorFromRoles,
type CreateRoleArgs,
createRole,
decorateRole,
@@ -15,7 +14,6 @@ export {
type OnFailOptions,
onFail,
type RoleDecorator,
type RoleDescriptorInput,
type WithDryRunOptions,
withDryRun,
} from "@uncaged/workflow-util-role";
@@ -8,7 +8,7 @@ import { createCommitterRole } from "../src/committer.js";
function makeCtx(): ThreadContext {
return {
threadId: "01TEST00000000000000000000",
threadId: "01TEST000000000000000000TR",
start: {
role: START,
content: "do thing",
@@ -29,7 +29,7 @@ function toolCallResponse(argsJson: string): Response {
function makeCtx(): ThreadContext {
return {
threadId: "01TEST00000000000000000000",
threadId: "01TEST000000000000000000TR",
start: {
role: START,
content: "task",
@@ -106,6 +106,6 @@ describe("createReviewerRole", () => {
{ cwd: "/proj" },
);
await role(makeCtx());
expect(seen).toContain("uncaged-workflow thread 01TEST00000000000000000000");
expect(seen).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
});
});
@@ -25,7 +25,7 @@ function makeCtx(
steps: ThreadContext<SolveIssueMeta>["steps"],
): ThreadContext<SolveIssueMeta> {
return {
threadId: "01TEST00000000000000000000",
threadId: "01TEST000000000000000000TR",
start: makeStart(maxRounds),
steps,
};
@@ -112,13 +112,13 @@ describe("createSolveIssueRoles", () => {
extract: null,
});
expect(typeof roles.planner).toBe("function");
expect(typeof roles.coder).toBe("function");
expect(typeof roles.reviewer).toBe("function");
expect(typeof roles.committer).toBe("function");
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(ctx as unknown as ThreadContext);
const plannerOut = await roles.planner.run(ctx as unknown as ThreadContext);
expect(plannerOut.meta.plan).toBe("");
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
});
@@ -1,34 +1,22 @@
import { committerMetaSchema } from "@uncaged/workflow-role-committer";
import { reviewerMetaSchema } from "@uncaged/workflow-role-reviewer";
import { buildDescriptorFromRoles } from "@uncaged/workflow-util-role";
import { buildDescriptor } from "@uncaged/workflow";
import { coderMetaSchema, plannerMetaSchema } from "./roles.js";
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,
};
export function buildSolveIssueDescriptor() {
return buildDescriptorFromRoles({
description:
"Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).",
roles: {
planner: {
name: "planner",
schema: plannerMetaSchema,
description: "Analyzes the issue and proposes plan, files, and approach.",
},
coder: {
name: "coder",
schema: coderMetaSchema,
description: "Implements the planner output and summarizes touched files.",
},
reviewer: {
name: "reviewer",
schema: reviewerMetaSchema,
description: "Runs git diff checks and sets approved when the change is ready.",
},
committer: {
name: "committer",
schema: committerMetaSchema,
description: "Creates branch, commits, and pushes when review passes.",
},
},
return buildDescriptor({
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: createSolveIssueRoles(BUILD_DESCRIPTOR_CONFIG),
moderator: solveIssueModerator,
});
}
@@ -1,7 +1,12 @@
import { createRoleModerator, type WorkflowFn } from "@uncaged/workflow";
import { createRoleModerator, type WorkflowDefinition, type WorkflowFn } from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.js";
import { createSolveIssueRoles, type SolveIssueMeta, type SolveIssueRolesConfig } from "./roles.js";
import {
createSolveIssueRoles,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
type SolveIssueRolesConfig,
} from "./roles.js";
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
export { buildSolveIssueDescriptor } from "./descriptor.js";
@@ -12,18 +17,26 @@ export {
createSolveIssueRoles,
type PlannerMeta,
plannerMetaSchema,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
type SolveIssueRoles,
type SolveIssueRolesConfig,
} from "./roles.js";
export function createSolveIssueWorkflowDefinition(
config: SolveIssueRolesConfig,
): WorkflowDefinition<SolveIssueMeta> {
return {
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: createSolveIssueRoles(config),
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<SolveIssueMeta>({
roles: createSolveIssueRoles(config),
moderator: solveIssueModerator,
});
return createRoleModerator(createSolveIssueWorkflowDefinition(config));
}
@@ -1,7 +1,15 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import type { AgentFn, RoleDefinition } from "@uncaged/workflow";
import { createRole } from "@uncaged/workflow-agent-llm";
import { type CommitterMeta, createCommitterRole } from "@uncaged/workflow-role-committer";
import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer";
import {
type CommitterMeta,
committerMetaSchema,
createCommitterRole,
} from "@uncaged/workflow-role-committer";
import {
createReviewerRole,
type ReviewerMeta,
reviewerMetaSchema,
} from "@uncaged/workflow-role-reviewer";
import type { LlmProvider } from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
@@ -19,6 +27,9 @@ const CODER_SYSTEM = `You are a **coder**. The previous step produced a plan: re
Make focused changes, follow project conventions, and explain what you changed.`;
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
"Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).";
export const plannerMetaSchema = z.object({
plan: z.string(),
files: z.array(z.string()),
@@ -80,10 +91,10 @@ function resolveExtract(config: SolveIssueRolesConfig): {
}
export type SolveIssueRoles = {
planner: Role<PlannerMeta>;
coder: Role<CoderMeta>;
reviewer: Role<ReviewerMeta>;
committer: Role<CommitterMeta>;
planner: RoleDefinition<PlannerMeta>;
coder: RoleDefinition<CoderMeta>;
reviewer: RoleDefinition<ReviewerMeta>;
committer: RoleDefinition<CommitterMeta>;
};
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
@@ -95,7 +106,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
cwd: config.workdir,
};
const planner: Role<PlannerMeta> = createRole({
const plannerRun = createRole({
name: "planner",
schema: plannerMetaSchema,
systemPrompt: PLANNER_SYSTEM,
@@ -107,7 +118,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
},
});
const coder: Role<CoderMeta> = createRole({
const coderRun = createRole({
name: "coder",
schema: coderMetaSchema,
systemPrompt: CODER_SYSTEM,
@@ -119,7 +130,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
},
});
const reviewer: Role<ReviewerMeta> = createReviewerRole(
const reviewerRun = createReviewerRole(
config.agent,
{
provider: extract.provider,
@@ -129,7 +140,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
reviewerConfig,
);
const committer: Role<CommitterMeta> = createCommitterRole(
const committerRun = createCommitterRole(
config.agent,
{
provider: extract.provider,
@@ -139,5 +150,26 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
committerConfig,
);
return { planner, coder, reviewer, committer };
return {
planner: {
description: "Analyzes the issue and proposes plan, files, and approach.",
run: plannerRun,
schema: plannerMetaSchema,
},
coder: {
description: "Implements the planner output and summarizes touched files.",
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,
},
};
}
@@ -1,74 +0,0 @@
import { describe, expect, test } from "bun:test";
import { validateWorkflowDescriptor } from "@uncaged/workflow";
import * as z from "zod/v4";
import { buildDescriptorFromRoles } from "../src/build-descriptor.js";
describe("buildDescriptorFromRoles", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => {
const schema = z.object({
title: z.string(),
count: z.number(),
});
const descriptor = buildDescriptorFromRoles({
description: "Demo workflow",
roles: {
analyst: {
name: "analyst",
schema,
description: "Analyzes input",
},
},
});
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
return;
}
expect(validated.value.description).toBe("Demo workflow");
const analyst = validated.value.roles.analyst;
expect(analyst.description).toBe("Analyzes input");
expect(analyst.schema.type).toBe("object");
const props = analyst.schema.properties as Record<string, unknown>;
expect(props.title).toMatchObject({ type: "string" });
expect(props.count).toMatchObject({ type: "number" });
});
test("uses empty description when spec.description is null", () => {
const descriptor = buildDescriptorFromRoles({
description: "W",
roles: {
x: {
name: "x",
schema: z.object({ n: z.number() }),
description: null,
},
},
});
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
return;
}
expect(validated.value.roles.x.description).toBe("");
});
test("throws when role key and spec.name diverge", () => {
expect(() =>
buildDescriptorFromRoles({
description: "W",
roles: {
a: {
name: "b",
schema: z.object({ n: z.number() }),
description: null,
},
},
}),
).toThrow(/must match spec.name/);
});
});
@@ -42,8 +42,8 @@ function makeCtx(): ThreadContext {
meta: { maxRounds: 10 },
timestamp: Date.now(),
},
steps: [],
threadId: "01TEST000000000000000000TR",
steps: [],
threadId: "01TEST000000000000000000TR",
};
}
@@ -16,8 +16,8 @@ function fakeCtx(): ThreadContext {
},
timestamp: Date.now(),
},
steps: [],
threadId: "01TEST000000000000000000TR",
steps: [],
threadId: "01TEST000000000000000000TR",
};
}
@@ -1,38 +0,0 @@
import type { WorkflowDescriptor, WorkflowRoleSchema } from "@uncaged/workflow";
import * as z from "zod/v4";
export type RoleDescriptorInput<M extends Record<string, unknown> = Record<string, unknown>> = {
name: string;
schema: z.ZodType<M>;
/** Human-readable role description; use empty string when unknown. */
description: string | null;
};
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json;
return rest as WorkflowRoleSchema;
}
/**
* Builds a {@link WorkflowDescriptor} from role specs, emitting JSON Schema per role via
* `z.toJSONSchema`.
*/
export function buildDescriptorFromRoles(args: {
description: string;
roles: Record<string, RoleDescriptorInput>;
}): WorkflowDescriptor {
const roles: WorkflowDescriptor["roles"] = {};
for (const [key, spec] of Object.entries(args.roles)) {
if (spec.name !== key) {
throw new Error(
`buildDescriptorFromRoles: role key "${key}" must match spec.name "${spec.name}"`,
);
}
const rawJsonSchema = z.toJSONSchema(spec.schema) as Record<string, unknown>;
roles[key] = {
description: spec.description === null ? "" : spec.description,
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
return { description: args.description, roles };
}
-1
View File
@@ -1,4 +1,3 @@
export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js";
export { type CreateRoleArgs, createRole } from "./create-role.js";
export {
decorateRole,
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import * as z from "zod/v4";
import { buildDescriptor } from "../src/build-descriptor.js";
import { END } from "../src/types.js";
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js";
describe("buildDescriptor", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => {
const schema = z.object({
title: z.string(),
count: z.number(),
});
type M = { analyst: z.infer<typeof schema> };
const descriptor = buildDescriptor<M>({
description: "Demo workflow",
roles: {
analyst: {
description: "Analyzes input",
schema,
run: async () => ({ content: "", meta: { title: "", count: 0 } }),
},
},
moderator: () => END,
});
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
return;
}
expect(validated.value.description).toBe("Demo workflow");
const analyst = validated.value.roles.analyst;
expect(analyst.description).toBe("Analyzes input");
expect(analyst.schema.type).toBe("object");
const props = analyst.schema.properties as Record<string, unknown>;
expect(props.title).toMatchObject({ type: "string" });
expect(props.count).toMatchObject({ type: "number" });
});
});
+28 -10
View File
@@ -2,27 +2,45 @@ import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
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 { executeThread } from "../src/engine.js";
import { createLogger } from "../src/logger.js";
import { END } from "../src/types.js";
const plannerMetaSchema = z.object({
plan: z.string(),
files: z.array(z.string()),
});
const coderMetaSchema = z.object({
diff: z.string(),
});
type DemoMeta = {
planner: Record<string, unknown>;
coder: Record<string, unknown>;
planner: z.infer<typeof plannerMetaSchema>;
coder: z.infer<typeof coderMetaSchema>;
};
const demoWorkflow = createRoleModerator<DemoMeta>({
roles: {
planner: async () => ({
content: "plan-body",
meta: { plan: "do-it", files: ["a.ts"] },
}),
coder: async () => ({
content: "code-body",
meta: { diff: "+ok" },
}),
planner: {
description: "Demo planner",
schema: plannerMetaSchema,
run: async () => ({
content: "plan-body",
meta: { plan: "do-it", files: ["a.ts"] },
}),
},
coder: {
description: "Demo coder",
schema: coderMetaSchema,
run: async () => ({
content: "code-body",
meta: { diff: "+ok" },
}),
},
},
moderator: (ctx) => {
if (ctx.steps.length === 0) {
+5 -1
View File
@@ -13,7 +13,11 @@
"xxhashjs": "^0.2.2",
"yaml": "^2.8.4"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"@types/acorn": "^6.0.4"
"@types/acorn": "^6.0.4",
"zod": "^4.0.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
import * as z from "zod/v4";
import type { RoleMeta, WorkflowDefinition } from "./types.js";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./workflow-descriptor.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json;
return rest as WorkflowRoleSchema;
}
export function buildDescriptor<M extends RoleMeta>(
def: WorkflowDefinition<M>,
): WorkflowDescriptor {
const roles: WorkflowDescriptor["roles"] = {};
for (const [key, roleDef] of Object.entries(def.roles) as Array<
[string, { description: string; schema: z.ZodType }]
>) {
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
roles[key] = {
description: roleDef.description,
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
return { description: def.description, roles };
}
@@ -65,12 +65,12 @@ export function createRoleModerator<M extends RoleMeta>(
return { returnCode: 0, summary: "completed: moderator returned END" };
}
const roleFn = def.roles[next];
if (roleFn === undefined) {
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { returnCode: 1, summary: `unknown role: ${next}` };
}
const result = await roleFn(ctx as unknown as ThreadContext);
const result = await roleDef.run(ctx as unknown as ThreadContext);
const ts = Date.now();
const step = {
role: next,
+2
View File
@@ -5,6 +5,7 @@ export {
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} 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 {
@@ -53,6 +54,7 @@ export {
END,
type Moderator,
type Role,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleResult,
+11 -1
View File
@@ -1,3 +1,5 @@
import type * as z from "zod/v4";
/** Sentinel values for automaton control flow. */
export const START = "__start__" as const;
export const END = "__end__" as const;
@@ -71,6 +73,13 @@ 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. */
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
run: Role<Meta>;
schema: z.ZodType<Meta>;
};
/**
* An Agent — raw string output interface for LLM/CLI adapters.
* Structured meta is extracted by the role's extract layer.
@@ -89,6 +98,7 @@ export type Moderator<M extends RoleMeta> = (
/** Complete workflow definition as authored by users. */
export type WorkflowDefinition<M extends RoleMeta> = {
roles: { [K in keyof M & string]: Role<M[K]> };
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
};