feat(workflow-protocol): add declarative moderator table types and tableToModerator

Add ModeratorCondition, FALLBACK, ModeratorTransition, ModeratorTable types
and tableToModerator converter function. Export from workflow-protocol and
re-export from workflow-runtime for backward compat.

Refs #172
This commit is contained in:
2026-05-11 01:58:12 +00:00
parent 93d9821f64
commit 6fc97fc8c8
5 changed files with 219 additions and 1 deletions
@@ -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<TestMeta> {
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<TestMeta> = {
[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<TestMeta> = {
[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<TestMeta> = {
[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<TestMeta> = {
[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<TestMeta> = {
[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<TestMeta> = {
[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<TestMeta> = {
[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");
});
});
+8
View File
@@ -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";
@@ -0,0 +1,24 @@
import type { Moderator, ModeratorTable, RoleMeta } from "./types.js";
import { END, START } from "./types.js";
export function tableToModerator<M extends RoleMeta>(
table: ModeratorTable<M>,
): Moderator<M> {
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<string, (typeof table)[string]>)[currentRole];
if (!transitions) {
return END;
}
for (const transition of transitions) {
if (transition.condition === "FALLBACK" || transition.condition.check(ctx)) {
return transition.role;
}
}
return END;
};
}
+22
View File
@@ -169,6 +169,28 @@ export type WorkflowDefinition<M extends RoleMeta> = {
moderator: Moderator<M>;
};
// ── Declarative Moderator Table ────────────────────────────────────
export type ModeratorCondition<M extends RoleMeta> = {
name: string;
description: string;
check: (ctx: ModeratorContext<M>) => boolean;
};
export type FALLBACK = "FALLBACK";
export type ModeratorTransition<M extends RoleMeta> = {
condition: ModeratorCondition<M> | FALLBACK;
role: (keyof M & string) | typeof END;
};
export type ModeratorTable<M extends RoleMeta> = Record<
(keyof M & string) | typeof START,
ModeratorTransition<M>[]
>;
// ── Advance Outcome ────────────────────────────────────────────────
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };