refactor: status-based graph routing + mustache prompt templates
- Delete ConditionDefinition, Transition types from workflow-protocol - Add Target type, change graph to Record<string, Record<string, Target>> - Remove conditions from WorkflowPayload and WORKFLOW_SCHEMA - Replace jsonata with mustache in workflow-moderator - Rewrite evaluate() to simple map lookup + mustache render - Update cli-workflow to use new 3-arg evaluate(graph, role, output) - 296 tests pass, 0 fail Phase 1 of #490 (closes #491)
This commit is contained in:
@@ -70,7 +70,6 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
// Basic structure validation
|
||||
expect(workflow.name).toBe("solve-issue");
|
||||
expect(workflow.roles).toBeDefined();
|
||||
expect(workflow.conditions).toBeDefined();
|
||||
expect(workflow.graph).toBeDefined();
|
||||
|
||||
// Verify committer role exists with required fields
|
||||
|
||||
@@ -25,7 +25,6 @@ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
||||
name,
|
||||
description: "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||
@@ -36,7 +35,6 @@ async function createWorkflowYaml(name: string, version: string | null = null):
|
||||
name,
|
||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
const yaml = stringify(payload);
|
||||
@@ -145,7 +143,7 @@ describe("Strategy 2: File Path Resolution", () => {
|
||||
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||
await makeUwfStore(storageRoot);
|
||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
|
||||
await writeFile(yamlPath, "name: test\n# missing roles and graph");
|
||||
|
||||
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ModeratorContext,
|
||||
StartNodePayload,
|
||||
StartOutput,
|
||||
StepContext,
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
ThreadId,
|
||||
@@ -53,6 +51,7 @@ import {
|
||||
import { materializeWorkflowPayload } from "./workflow.js";
|
||||
|
||||
const END_ROLE = "$END";
|
||||
const START_ROLE = "$START";
|
||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||
|
||||
const PL_THREAD_START = "7HNQ4B2X";
|
||||
@@ -670,17 +669,32 @@ function formatThreadReadMarkdown(options: {
|
||||
return parts.join("\n\n---\n\n");
|
||||
}
|
||||
|
||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
||||
const steps: StepContext[] = chronological.map((step) => ({
|
||||
role: step.role,
|
||||
output: expandOutput(uwf, step.output),
|
||||
detail: step.detail,
|
||||
agent: step.agent,
|
||||
edgePrompt: step.edgePrompt ?? "",
|
||||
content: null, // Moderator doesn't need content
|
||||
}));
|
||||
return { start: chain.start, steps };
|
||||
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
function resolveEvaluateArgs(
|
||||
uwf: UwfStore,
|
||||
chain: ChainState,
|
||||
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||
if (chain.headIsStart) {
|
||||
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||
}
|
||||
|
||||
const lastStep = chain.stepsNewestFirst[0];
|
||||
if (lastStep === undefined) {
|
||||
fail("empty step chain");
|
||||
}
|
||||
|
||||
const raw = expandOutput(uwf, lastStep.output);
|
||||
const base =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
const status = typeof base.status === "string" ? base.status : "_";
|
||||
|
||||
return {
|
||||
lastRole: lastStep.role,
|
||||
lastOutput: { ...base, status },
|
||||
};
|
||||
}
|
||||
|
||||
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||
@@ -924,9 +938,9 @@ async function cmdThreadStepOnce(
|
||||
const chain = walkChain(uwf, headHash);
|
||||
const workflowHash = chain.start.workflow;
|
||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
||||
const context = buildModeratorContext(uwf, chain);
|
||||
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
|
||||
|
||||
const nextResult = await evaluate(workflow, context);
|
||||
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
|
||||
if (!nextResult.ok) {
|
||||
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
||||
}
|
||||
@@ -976,8 +990,11 @@ async function cmdThreadStepOnce(
|
||||
await saveThreadsIndex(storageRoot, freshIndex);
|
||||
|
||||
const chainAfter = walkChain(uwfAfter, newHead);
|
||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
||||
const afterResult = await evaluate(workflow, contextAfter);
|
||||
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
|
||||
uwfAfter,
|
||||
chainAfter,
|
||||
);
|
||||
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
|
||||
if (!afterResult.ok) {
|
||||
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
||||
const result: Record<string, Transition[]> = {};
|
||||
for (const [node, transitions] of Object.entries(graph)) {
|
||||
result[node] = transitions.map((t) => {
|
||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
||||
/** Normalize graph: validate each status → target mapping. */
|
||||
function normalizeGraph(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
): Record<string, Record<string, Target>> {
|
||||
const result: Record<string, Record<string, Target>> = {};
|
||||
for (const [node, statusMap] of Object.entries(graph)) {
|
||||
const normalized: Record<string, Target> = {};
|
||||
for (const [status, target] of Object.entries(statusMap)) {
|
||||
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
|
||||
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
|
||||
}
|
||||
return {
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
prompt: t.prompt,
|
||||
normalized[status] = {
|
||||
role: target.role,
|
||||
prompt: target.prompt,
|
||||
};
|
||||
});
|
||||
}
|
||||
result[node] = normalized;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -106,7 +104,6 @@ export async function materializeWorkflowPayload(
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,23 +30,12 @@ function isRoleDefinition(value: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isConditionDefinition(value: unknown): boolean {
|
||||
function isTarget(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
return typeof value.description === "string" && typeof value.expression === "string";
|
||||
}
|
||||
|
||||
function isTransition(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
typeof value.prompt === "string" &&
|
||||
value.prompt.trim() !== "" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
return Object.values(value).every(
|
||||
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
|
||||
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,11 +90,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
|
||||
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
||||
!isGraph(raw.graph)
|
||||
) {
|
||||
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||
return null;
|
||||
}
|
||||
return raw as WorkflowPayload;
|
||||
|
||||
@@ -1,312 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { evaluate } from "../src/evaluate.js";
|
||||
|
||||
const solveIssueWorkflow: WorkflowPayload = {
|
||||
name: "solve-issue",
|
||||
description: "End-to-end issue resolution",
|
||||
roles: {
|
||||
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||
$START: {
|
||||
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
},
|
||||
planner: {
|
||||
description: "Creates implementation plan",
|
||||
goal: "You are a planning agent.",
|
||||
capabilities: ["planning"],
|
||||
procedure: "Create a step-by-step plan.",
|
||||
output: "Output the plan and steps.",
|
||||
frontmatter: "5GWKR8TN1V3JA",
|
||||
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||
},
|
||||
developer: {
|
||||
description: "Implements code changes",
|
||||
goal: "You are a developer agent.",
|
||||
capabilities: ["coding"],
|
||||
procedure: "Implement the plan.",
|
||||
output: "List files changed and summary.",
|
||||
frontmatter: "8CNWT4KR6D1HV",
|
||||
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||
},
|
||||
reviewer: {
|
||||
description: "Reviews code changes",
|
||||
goal: "You are a code reviewer.",
|
||||
capabilities: ["code-review"],
|
||||
procedure: "Review the implementation.",
|
||||
output: "Approve or reject with comments.",
|
||||
frontmatter: "1VPBG9SM5E7WK",
|
||||
},
|
||||
},
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists($last('planner').needsClarification)",
|
||||
},
|
||||
rejected: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "$last('reviewer').approved = false",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Start planning from the issue in the task.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: "needsClarification",
|
||||
prompt: "Clarification is needed; hand off to developer.",
|
||||
},
|
||||
{ role: "$END", condition: null, prompt: "Planning complete; end workflow." },
|
||||
],
|
||||
developer: [
|
||||
{
|
||||
role: "reviewer",
|
||||
condition: null,
|
||||
prompt: "Implementation done; send to reviewer.",
|
||||
},
|
||||
],
|
||||
reviewer: [
|
||||
{
|
||||
role: "developer",
|
||||
condition: "rejected",
|
||||
prompt: "Reviewer rejected; return to developer.",
|
||||
},
|
||||
{ role: "$END", condition: null, prompt: "Review passed; end workflow." },
|
||||
],
|
||||
approved: { role: "$END", prompt: "Done." },
|
||||
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||
},
|
||||
};
|
||||
|
||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
||||
return {
|
||||
start: {
|
||||
workflow: "4KNM2PXR3B1QW",
|
||||
prompt: "Fix the login bug",
|
||||
},
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
describe("evaluate", () => {
|
||||
test("$START → first role (fallback)", async () => {
|
||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
||||
test("$START → first role (unit status _)", () => {
|
||||
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||
});
|
||||
});
|
||||
|
||||
test("condition match (rejected → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("status-based routing (reviewer rejected → developer)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||
status: "rejected",
|
||||
comments: "missing tests",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Reviewer rejected; return to developer." },
|
||||
value: { role: "developer", prompt: "Fix: missing tests" },
|
||||
});
|
||||
});
|
||||
|
||||
test("fallback when condition does not match → $END", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: true },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("status-based routing (reviewer approved → $END)", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Review passed; end workflow." },
|
||||
value: { role: "$END", prompt: "Done." },
|
||||
});
|
||||
});
|
||||
|
||||
test("missing role in graph → error", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "unknown-role",
|
||||
output: {},
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("missing role in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||
}
|
||||
});
|
||||
|
||||
test("output expansion in context works with JSONata", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { needsClarification: true },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
test("missing status in graph → error", () => {
|
||||
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||
}
|
||||
});
|
||||
|
||||
test("mustache template rendering with simple fields", () => {
|
||||
const result = evaluate(solveIssueGraph, "planner", {
|
||||
status: "_",
|
||||
plan: "Add auth middleware",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "Clarification is needed; hand off to developer." },
|
||||
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
|
||||
});
|
||||
});
|
||||
|
||||
test("$last returns most recent matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
devFailed: {
|
||||
description: "Developer failed",
|
||||
expression: "$last('developer').status = 'failed'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
test("mustache template with nested object paths", () => {
|
||||
const graph: Record<string, Record<string, Target>> = {
|
||||
reviewer: {
|
||||
_: {
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "Begin development.",
|
||||
prompt: "Address: {{review.comments}}",
|
||||
},
|
||||
],
|
||||
developer: [
|
||||
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
|
||||
{
|
||||
role: "reviewer",
|
||||
condition: null,
|
||||
prompt: "Development succeeded; review.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "reviewer",
|
||||
output: { approved: false },
|
||||
detail: "2MXBG6PN4A8JR",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "failed" },
|
||||
detail: "3QNTH7WK8D2PA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
const result = evaluate(graph, "reviewer", {
|
||||
status: "_",
|
||||
review: { comments: "refactor the handler" },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "Development failed; end." },
|
||||
});
|
||||
});
|
||||
|
||||
test("$first returns earliest matching role's frontmatter", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
firstPlanReady: {
|
||||
description: "First planner run was ready",
|
||||
expression: "$first('planner').status = 'ready'",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Begin planning.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{ role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." },
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "Plan not ready on first pass; implement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready", plan: "ABC123" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "revised", plan: "DEF456" },
|
||||
detail: "4RNMK6PX8B3WQ",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "$END", prompt: "First plan was ready; end." },
|
||||
});
|
||||
});
|
||||
|
||||
test("$last returns undefined for unmatched role", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
hasReviewer: {
|
||||
description: "Reviewer has run",
|
||||
expression: "$exists($last('reviewer'))",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [
|
||||
{
|
||||
role: "planner",
|
||||
condition: null,
|
||||
prompt: "Begin planning.",
|
||||
},
|
||||
],
|
||||
planner: [
|
||||
{ role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." },
|
||||
{
|
||||
role: "developer",
|
||||
condition: null,
|
||||
prompt: "No reviewer yet; implement.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
// no reviewer step → $exists returns false → fallback to developer
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { role: "developer", prompt: "No reviewer yet; implement." },
|
||||
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"jsonata": "^1.8.7"
|
||||
"mustache": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mustache": "^4.2.6",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,65 +1,39 @@
|
||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import jsonata from "jsonata";
|
||||
import type { Target } from "@uncaged/workflow-protocol";
|
||||
import mustache from "mustache";
|
||||
|
||||
import type { EvaluateResult, Result } from "./types.js";
|
||||
|
||||
const START_ROLE = "$START";
|
||||
const UNIT_STATUS = "_";
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value !== 0 && !Number.isNaN(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.length > 0;
|
||||
}
|
||||
return true;
|
||||
type LastOutput = Record<string, unknown> & { status: string };
|
||||
|
||||
export function evaluate(
|
||||
graph: Record<string, Record<string, Target>>,
|
||||
lastRole: string,
|
||||
lastOutput: LastOutput,
|
||||
): Result<EvaluateResult, Error> {
|
||||
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||
|
||||
const roleTargets = graph[lastRole];
|
||||
if (roleTargets === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||
};
|
||||
}
|
||||
|
||||
function findByRole(
|
||||
steps: ModeratorContext["steps"],
|
||||
role: string,
|
||||
direction: "first" | "last",
|
||||
): unknown {
|
||||
if (direction === "last") {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
if (steps[i].role === role) {
|
||||
return steps[i].output;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const step of steps) {
|
||||
if (step.role === role) {
|
||||
return step.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
const target = roleTargets[status];
|
||||
if (target === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
|
||||
};
|
||||
}
|
||||
|
||||
async function evaluateJsonata(
|
||||
expression: string,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<unknown, Error>> {
|
||||
try {
|
||||
const expr = jsonata(expression);
|
||||
expr.registerFunction(
|
||||
"first",
|
||||
(role: string) => findByRole(context.steps, role, "first"),
|
||||
"<s:x>",
|
||||
);
|
||||
expr.registerFunction(
|
||||
"last",
|
||||
(role: string) => findByRole(context.steps, role, "last"),
|
||||
"<s:x>",
|
||||
);
|
||||
const result = await expr.evaluate(context);
|
||||
return { ok: true, value: result };
|
||||
const prompt = mustache.render(target.prompt, lastOutput);
|
||||
return { ok: true, value: { role: target.role, prompt } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -67,51 +41,3 @@ async function evaluateJsonata(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function currentRole(context: ModeratorContext): string {
|
||||
if (context.steps.length === 0) {
|
||||
return START_ROLE;
|
||||
}
|
||||
return context.steps[context.steps.length - 1].role;
|
||||
}
|
||||
|
||||
export async function evaluate(
|
||||
workflow: WorkflowPayload,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<EvaluateResult, Error>> {
|
||||
const role = currentRole(context);
|
||||
const transitions = workflow.graph[role];
|
||||
if (transitions === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transitions defined for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
if (transition.condition === null) {
|
||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
||||
}
|
||||
|
||||
const conditionDef = workflow.conditions[transition.condition];
|
||||
if (conditionDef === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`unknown condition "${transition.condition}"`),
|
||||
};
|
||||
}
|
||||
|
||||
const evalResult = await evaluateJsonata(conditionDef.expression, context);
|
||||
if (!evalResult.ok) {
|
||||
return evalResult;
|
||||
}
|
||||
if (isTruthy(evalResult.value)) {
|
||||
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`no transition matched for role "${role}"`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type {
|
||||
AgentAlias,
|
||||
AgentConfig,
|
||||
CasRef,
|
||||
ConditionDefinition,
|
||||
ModelAlias,
|
||||
ModelConfig,
|
||||
ModeratorContext,
|
||||
@@ -26,12 +25,12 @@ export type {
|
||||
StepNodePayload,
|
||||
StepOutput,
|
||||
StepRecord,
|
||||
Target,
|
||||
ThreadForkOutput,
|
||||
ThreadId,
|
||||
ThreadListItem,
|
||||
ThreadStepsOutput,
|
||||
ThreadsIndex,
|
||||
Transition,
|
||||
WorkflowConfig,
|
||||
WorkflowName,
|
||||
WorkflowPayload,
|
||||
|
||||
@@ -14,22 +14,11 @@ const ROLE_DEFINITION: JSONSchema = {
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const CONDITION_DEFINITION: JSONSchema = {
|
||||
const TARGET: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["description", "expression"],
|
||||
properties: {
|
||||
description: { type: "string" },
|
||||
expression: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const TRANSITION: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["role", "condition", "prompt"],
|
||||
required: ["role", "prompt"],
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
prompt: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -38,7 +27,7 @@ const TRANSITION: JSONSchema = {
|
||||
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
title: "Workflow",
|
||||
type: "object",
|
||||
required: ["name", "description", "roles", "conditions", "graph"],
|
||||
required: ["name", "description", "roles", "graph"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
@@ -46,15 +35,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
additionalProperties: ROLE_DEFINITION,
|
||||
},
|
||||
conditions: {
|
||||
type: "object",
|
||||
additionalProperties: CONDITION_DEFINITION,
|
||||
},
|
||||
graph: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: TRANSITION,
|
||||
type: "object",
|
||||
additionalProperties: TARGET,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -27,23 +27,16 @@ export type RoleDefinition = {
|
||||
frontmatter: CasRef;
|
||||
};
|
||||
|
||||
export type Transition = {
|
||||
export type Target = {
|
||||
role: string;
|
||||
condition: string | null;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type ConditionDefinition = {
|
||||
description: string;
|
||||
expression: string;
|
||||
};
|
||||
|
||||
export type WorkflowPayload = {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Record<string, RoleDefinition>;
|
||||
conditions: Record<string, ConditionDefinition>;
|
||||
graph: Record<string, Transition[]>;
|
||||
graph: Record<string, Record<string, Target>>;
|
||||
};
|
||||
|
||||
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user