diff --git a/packages/workflow-protocol/__tests__/moderator-table.test.ts b/packages/workflow-protocol/__tests__/moderator-table.test.ts new file mode 100644 index 0000000..f06a73a --- /dev/null +++ b/packages/workflow-protocol/__tests__/moderator-table.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { tableToModerator } from "../src/moderator-table.js"; +import { END, START } from "../src/types.js"; +import type { + ModeratorContext, + ModeratorTable, + RoleMeta, + StartStep, +} from "../src/types.js"; + +type TestMeta = { + planner: { plan: string }; + coder: { code: string }; + reviewer: { approved: boolean }; +}; + +function makeCtx( + roles: (keyof TestMeta & string)[], +): ModeratorContext { + const steps = roles.map((role, i) => ({ + role, + meta: {} as TestMeta[typeof role], + contentHash: `hash-${i}`, + refs: [], + timestamp: Date.now() + i, + })); + return { + threadId: "test-thread", + depth: 0, + start: { + role: START, + content: "test", + meta: { maxRounds: 10 }, + timestamp: Date.now(), + } as StartStep, + steps, + }; +} + +describe("tableToModerator", () => { + test("START -> role A (FALLBACK) returns A on first call", () => { + const table: ModeratorTable = { + [START]: [{ condition: "FALLBACK", role: "planner" }], + planner: [], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + expect(mod(makeCtx([]))).toBe("planner"); + }); + + test("condition true wins over FALLBACK", () => { + const table: ModeratorTable = { + [START]: [ + { + condition: { + name: "always", + description: "always true", + check: () => true, + }, + role: "planner", + }, + { condition: "FALLBACK", role: "coder" }, + ], + planner: [], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + expect(mod(makeCtx([]))).toBe("planner"); + }); + + test("condition false falls through to FALLBACK", () => { + const table: ModeratorTable = { + [START]: [ + { + condition: { + name: "never", + description: "always false", + check: () => false, + }, + role: "planner", + }, + { condition: "FALLBACK", role: "coder" }, + ], + planner: [], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + expect(mod(makeCtx([]))).toBe("coder"); + }); + + test("no matching transitions returns END", () => { + const table: ModeratorTable = { + [START]: [ + { + condition: { + name: "never", + description: "always false", + check: () => false, + }, + role: "planner", + }, + ], + planner: [], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + expect(mod(makeCtx([]))).toBe(END); + }); + + test("multi-step: A -> FALLBACK END returns END after A", () => { + const table: ModeratorTable = { + [START]: [{ condition: "FALLBACK", role: "planner" }], + planner: [{ condition: "FALLBACK", role: END }], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + expect(mod(makeCtx(["planner"]))).toBe(END); + }); + + test("role not in table returns END", () => { + const table: ModeratorTable = { + [START]: [{ condition: "FALLBACK", role: "planner" }], + planner: [{ condition: "FALLBACK", role: "coder" }], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + // coder has empty transitions array + expect(mod(makeCtx(["planner", "coder"]))).toBe(END); + }); + + test("condition receives ctx", () => { + const table: ModeratorTable = { + [START]: [ + { + condition: { + name: "has-steps", + description: "checks ctx.steps", + check: (ctx) => ctx.steps.length > 0, + }, + role: "coder", + }, + { condition: "FALLBACK", role: "planner" }, + ], + planner: [], + coder: [], + reviewer: [], + }; + const mod = tableToModerator(table); + // No steps -> condition false -> FALLBACK -> planner + expect(mod(makeCtx([]))).toBe("planner"); + }); +}); diff --git a/packages/workflow-protocol/src/index.ts b/packages/workflow-protocol/src/index.ts index f367bd5..55f75ff 100644 --- a/packages/workflow-protocol/src/index.ts +++ b/packages/workflow-protocol/src/index.ts @@ -17,9 +17,13 @@ export type { ExtractContext, ExtractFn, ExtractResult, + FALLBACK, LlmProvider, Moderator, + ModeratorCondition, ModeratorContext, + ModeratorTable, + ModeratorTransition, ProviderConfig, ResolvedModel, Result, @@ -47,3 +51,7 @@ export { END, START } from "./types.js"; // ── Constructor functions ────────────────────────────────────────── export { err, ok } from "./result.js"; + +// ── Moderator Table ──────────────────────────────────────────────── + +export { tableToModerator } from "./moderator-table.js"; diff --git a/packages/workflow-protocol/src/moderator-table.ts b/packages/workflow-protocol/src/moderator-table.ts new file mode 100644 index 0000000..e43b37f --- /dev/null +++ b/packages/workflow-protocol/src/moderator-table.ts @@ -0,0 +1,24 @@ +import type { Moderator, ModeratorTable, RoleMeta } from "./types.js"; +import { END, START } from "./types.js"; + +export function tableToModerator( + table: ModeratorTable, +): Moderator { + return (ctx) => { + const lastStep = ctx.steps.length > 0 ? ctx.steps[ctx.steps.length - 1] : null; + const currentRole: string = lastStep ? lastStep.role : START; + + const transitions = (table as Record)[currentRole]; + if (!transitions) { + return END; + } + + for (const transition of transitions) { + if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) { + return transition.role; + } + } + + return END; + }; +} diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 54cff5d..3034344 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -169,6 +169,28 @@ export type WorkflowDefinition = { moderator: Moderator; }; +// ── Declarative Moderator Table ──────────────────────────────────── + +export type ModeratorCondition = { + name: string; + description: string; + check: (ctx: ModeratorContext) => boolean; +}; + +export type FALLBACK = "FALLBACK"; + +export type ModeratorTransition = { + condition: ModeratorCondition | FALLBACK; + role: (keyof M & string) | typeof END; +}; + +export type ModeratorTable = Record< + (keyof M & string) | typeof START, + ModeratorTransition[] +>; + +// ── Advance Outcome ──────────────────────────────────────────────── + export type AdvanceOutcome = | { kind: "complete"; completion: WorkflowCompletion } | { kind: "yield"; output: RoleOutput; step: RoleStep }; diff --git a/packages/workflow-runtime/src/types.ts b/packages/workflow-runtime/src/types.ts index 8830db2..7ad000b 100644 --- a/packages/workflow-runtime/src/types.ts +++ b/packages/workflow-runtime/src/types.ts @@ -11,9 +11,15 @@ export type { ExtractContext, ExtractFn, ExtractResult, + FALLBACK, LlmProvider, Moderator, + ModeratorCondition, ModeratorContext, + ModeratorTable, + ModeratorTransition, + ProviderConfig, + ResolvedModel, Result, RoleDefinition, RoleMeta, @@ -31,4 +37,4 @@ export type { WorkflowRuntime, } from "@uncaged/workflow-protocol"; -export { END, START } from "@uncaged/workflow-protocol"; +export { END, START, tableToModerator } from "@uncaged/workflow-protocol";