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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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> };
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user