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
|
// Basic structure validation
|
||||||
expect(workflow.name).toBe("solve-issue");
|
expect(workflow.name).toBe("solve-issue");
|
||||||
expect(workflow.roles).toBeDefined();
|
expect(workflow.roles).toBeDefined();
|
||||||
expect(workflow.conditions).toBeDefined();
|
|
||||||
expect(workflow.graph).toBeDefined();
|
expect(workflow.graph).toBeDefined();
|
||||||
|
|
||||||
// Verify committer role exists with required fields
|
// Verify committer role exists with required fields
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
|
|||||||
name,
|
name,
|
||||||
description: "Test workflow",
|
description: "Test workflow",
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
return await uwf.store.put(uwf.schemas.workflow, payload);
|
return await uwf.store.put(uwf.schemas.workflow, payload);
|
||||||
@@ -36,7 +35,6 @@ async function createWorkflowYaml(name: string, version: string | null = null):
|
|||||||
name,
|
name,
|
||||||
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
description: version !== null ? `Test workflow (${version})` : "Test workflow",
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
const yaml = stringify(payload);
|
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 () => {
|
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
|
||||||
await makeUwfStore(storageRoot);
|
await makeUwfStore(storageRoot);
|
||||||
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
|
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();
|
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import type {
|
|||||||
AgentAlias,
|
AgentAlias,
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ModeratorContext,
|
|
||||||
StartNodePayload,
|
StartNodePayload,
|
||||||
StartOutput,
|
StartOutput,
|
||||||
StepContext,
|
|
||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
@@ -53,6 +51,7 @@ import {
|
|||||||
import { materializeWorkflowPayload } from "./workflow.js";
|
import { materializeWorkflowPayload } from "./workflow.js";
|
||||||
|
|
||||||
const END_ROLE = "$END";
|
const END_ROLE = "$END";
|
||||||
|
const START_ROLE = "$START";
|
||||||
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
export const THREAD_READ_DEFAULT_QUOTA = 4000;
|
||||||
|
|
||||||
const PL_THREAD_START = "7HNQ4B2X";
|
const PL_THREAD_START = "7HNQ4B2X";
|
||||||
@@ -670,17 +669,32 @@ function formatThreadReadMarkdown(options: {
|
|||||||
return parts.join("\n\n---\n\n");
|
return parts.join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
|
type EvaluateLastOutput = Record<string, unknown> & { status: string };
|
||||||
const chronological = [...chain.stepsNewestFirst].reverse();
|
|
||||||
const steps: StepContext[] = chronological.map((step) => ({
|
function resolveEvaluateArgs(
|
||||||
role: step.role,
|
uwf: UwfStore,
|
||||||
output: expandOutput(uwf, step.output),
|
chain: ChainState,
|
||||||
detail: step.detail,
|
): { lastRole: string; lastOutput: EvaluateLastOutput } {
|
||||||
agent: step.agent,
|
if (chain.headIsStart) {
|
||||||
edgePrompt: step.edgePrompt ?? "",
|
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
|
||||||
content: null, // Moderator doesn't need content
|
}
|
||||||
}));
|
|
||||||
return { start: chain.start, steps };
|
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 {
|
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
|
||||||
@@ -924,9 +938,9 @@ async function cmdThreadStepOnce(
|
|||||||
const chain = walkChain(uwf, headHash);
|
const chain = walkChain(uwf, headHash);
|
||||||
const workflowHash = chain.start.workflow;
|
const workflowHash = chain.start.workflow;
|
||||||
const workflow = loadWorkflowPayload(uwf, workflowHash);
|
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) {
|
if (!nextResult.ok) {
|
||||||
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
|
||||||
}
|
}
|
||||||
@@ -976,8 +990,11 @@ async function cmdThreadStepOnce(
|
|||||||
await saveThreadsIndex(storageRoot, freshIndex);
|
await saveThreadsIndex(storageRoot, freshIndex);
|
||||||
|
|
||||||
const chainAfter = walkChain(uwfAfter, newHead);
|
const chainAfter = walkChain(uwfAfter, newHead);
|
||||||
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
|
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
|
||||||
const afterResult = await evaluate(workflow, contextAfter);
|
uwfAfter,
|
||||||
|
chainAfter,
|
||||||
|
);
|
||||||
|
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
|
||||||
if (!afterResult.ok) {
|
if (!afterResult.ok) {
|
||||||
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
|
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 type { JSONSchema } from "@uncaged/json-cas";
|
||||||
import { putSchema, validate } from "@uncaged/json-cas";
|
import { putSchema, validate } from "@uncaged/json-cas";
|
||||||
import type {
|
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||||
CasRef,
|
|
||||||
RoleDefinition,
|
|
||||||
Transition,
|
|
||||||
WorkflowPayload,
|
|
||||||
} from "@uncaged/workflow-protocol";
|
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
|
|||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
|
/** Normalize graph: validate each status → target mapping. */
|
||||||
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
|
function normalizeGraph(
|
||||||
const result: Record<string, Transition[]> = {};
|
graph: Record<string, Record<string, Target>>,
|
||||||
for (const [node, transitions] of Object.entries(graph)) {
|
): Record<string, Record<string, Target>> {
|
||||||
result[node] = transitions.map((t) => {
|
const result: Record<string, Record<string, Target>> = {};
|
||||||
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
|
for (const [node, statusMap] of Object.entries(graph)) {
|
||||||
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
|
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 {
|
normalized[status] = {
|
||||||
role: t.role,
|
role: target.role,
|
||||||
condition: t.condition ?? null,
|
prompt: target.prompt,
|
||||||
prompt: t.prompt,
|
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
result[node] = normalized;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -106,7 +104,6 @@ export async function materializeWorkflowPayload(
|
|||||||
name: raw.name,
|
name: raw.name,
|
||||||
description: raw.description,
|
description: raw.description,
|
||||||
roles,
|
roles,
|
||||||
conditions: raw.conditions,
|
|
||||||
graph: normalizeGraph(raw.graph),
|
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)) {
|
if (!isRecord(value)) {
|
||||||
return false;
|
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 (
|
return (
|
||||||
typeof value.role === "string" &&
|
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
|
||||||
typeof value.prompt === "string" &&
|
|
||||||
value.prompt.trim() !== "" &&
|
|
||||||
(condition === null || condition === undefined || typeof condition === "string")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return Object.values(value).every(
|
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") {
|
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
|
||||||
!isStringRecord(raw.roles, isRoleDefinition) ||
|
|
||||||
!isStringRecord(raw.conditions, isConditionDefinition) ||
|
|
||||||
!isGraph(raw.graph)
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return raw as WorkflowPayload;
|
return raw as WorkflowPayload;
|
||||||
|
|||||||
@@ -1,312 +1,95 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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";
|
import { evaluate } from "../src/evaluate.js";
|
||||||
|
|
||||||
const solveIssueWorkflow: WorkflowPayload = {
|
const solveIssueGraph: WorkflowPayload["graph"] = {
|
||||||
name: "solve-issue",
|
$START: {
|
||||||
description: "End-to-end issue resolution",
|
_: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||||
roles: {
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
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: {
|
planner: {
|
||||||
needsClarification: {
|
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
|
||||||
description: "Planner requests clarification from user",
|
|
||||||
expression: "$exists($last('planner').needsClarification)",
|
|
||||||
},
|
|
||||||
rejected: {
|
|
||||||
description: "Reviewer rejected the implementation",
|
|
||||||
expression: "$last('reviewer').approved = false",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
graph: {
|
developer: {
|
||||||
$START: [
|
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
|
||||||
{
|
},
|
||||||
role: "planner",
|
reviewer: {
|
||||||
condition: null,
|
approved: { role: "$END", prompt: "Done." },
|
||||||
prompt: "Start planning from the issue in the task.",
|
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
|
||||||
},
|
|
||||||
],
|
|
||||||
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." },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
|
|
||||||
return {
|
|
||||||
start: {
|
|
||||||
workflow: "4KNM2PXR3B1QW",
|
|
||||||
prompt: "Fix the login bug",
|
|
||||||
},
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("evaluate", () => {
|
describe("evaluate", () => {
|
||||||
test("$START → first role (fallback)", async () => {
|
test("$START → first role (unit status _)", () => {
|
||||||
const result = await evaluate(solveIssueWorkflow, makeContext([]));
|
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
value: { role: "planner", prompt: "Start planning from the issue in the task." },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("condition match (rejected → developer)", async () => {
|
test("status-based routing (reviewer rejected → developer)", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "reviewer", {
|
||||||
{
|
status: "rejected",
|
||||||
role: "reviewer",
|
comments: "missing tests",
|
||||||
output: { approved: false },
|
});
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
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 () => {
|
test("status-based routing (reviewer approved → $END)", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
|
||||||
{
|
|
||||||
role: "reviewer",
|
|
||||||
output: { approved: true },
|
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "$END", prompt: "Review passed; end workflow." },
|
value: { role: "$END", prompt: "Done." },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing role in graph → error", async () => {
|
test("missing role in graph → error", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
|
||||||
{
|
|
||||||
role: "unknown-role",
|
|
||||||
output: {},
|
|
||||||
detail: "2MXBG6PN4A8JR",
|
|
||||||
agent: "uwf-hermes",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("output expansion in context works with JSONata", async () => {
|
test("missing status in graph → error", () => {
|
||||||
const context = makeContext([
|
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
|
||||||
{
|
expect(result.ok).toBe(false);
|
||||||
role: "planner",
|
if (!result.ok) {
|
||||||
output: { needsClarification: true },
|
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
|
||||||
detail: "7BQST3VW9F2MA",
|
}
|
||||||
agent: "uwf-hermes",
|
});
|
||||||
},
|
|
||||||
]);
|
test("mustache template rendering with simple fields", () => {
|
||||||
const result = await evaluate(solveIssueWorkflow, context);
|
const result = evaluate(solveIssueGraph, "planner", {
|
||||||
|
status: "_",
|
||||||
|
plan: "Add auth middleware",
|
||||||
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
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 () => {
|
test("mustache template with nested object paths", () => {
|
||||||
const workflow: WorkflowPayload = {
|
const graph: Record<string, Record<string, Target>> = {
|
||||||
...solveIssueWorkflow,
|
reviewer: {
|
||||||
conditions: {
|
_: {
|
||||||
devFailed: {
|
role: "developer",
|
||||||
description: "Developer failed",
|
prompt: "Address: {{review.comments}}",
|
||||||
expression: "$last('developer').status = 'failed'",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
graph: {
|
|
||||||
$START: [
|
|
||||||
{
|
|
||||||
role: "developer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Begin development.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
developer: [
|
|
||||||
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
|
|
||||||
{
|
|
||||||
role: "reviewer",
|
|
||||||
condition: null,
|
|
||||||
prompt: "Development succeeded; review.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const context = makeContext([
|
const result = evaluate(graph, "reviewer", {
|
||||||
{
|
status: "_",
|
||||||
role: "developer",
|
review: { comments: "refactor the handler" },
|
||||||
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);
|
|
||||||
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({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: { role: "$END", prompt: "First plan was ready; end." },
|
value: { role: "developer", prompt: "Address: refactor the handler" },
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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." },
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,9 +19,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow-protocol": "workspace:^",
|
"@uncaged/workflow-protocol": "workspace:^",
|
||||||
"jsonata": "^1.8.7"
|
"mustache": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/mustache": "^4.2.6",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
|
|||||||
@@ -1,65 +1,39 @@
|
|||||||
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
|
import type { Target } from "@uncaged/workflow-protocol";
|
||||||
import jsonata from "jsonata";
|
import mustache from "mustache";
|
||||||
|
|
||||||
import type { EvaluateResult, Result } from "./types.js";
|
import type { EvaluateResult, Result } from "./types.js";
|
||||||
|
|
||||||
const START_ROLE = "$START";
|
const START_ROLE = "$START";
|
||||||
|
const UNIT_STATUS = "_";
|
||||||
|
|
||||||
function isTruthy(value: unknown): boolean {
|
type LastOutput = Record<string, unknown> & { status: string };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findByRole(
|
export function evaluate(
|
||||||
steps: ModeratorContext["steps"],
|
graph: Record<string, Record<string, Target>>,
|
||||||
role: string,
|
lastRole: string,
|
||||||
direction: "first" | "last",
|
lastOutput: LastOutput,
|
||||||
): unknown {
|
): Result<EvaluateResult, Error> {
|
||||||
if (direction === "last") {
|
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
|
||||||
for (let i = steps.length - 1; i >= 0; i--) {
|
|
||||||
if (steps[i].role === role) {
|
const roleTargets = graph[lastRole];
|
||||||
return steps[i].output;
|
if (roleTargets === undefined) {
|
||||||
}
|
return {
|
||||||
}
|
ok: false,
|
||||||
} else {
|
error: new Error(`no transitions defined for role "${lastRole}"`),
|
||||||
for (const step of steps) {
|
};
|
||||||
if (step.role === role) {
|
}
|
||||||
return step.output;
|
|
||||||
}
|
const target = roleTargets[status];
|
||||||
}
|
if (target === undefined) {
|
||||||
}
|
return {
|
||||||
return undefined;
|
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 {
|
try {
|
||||||
const expr = jsonata(expression);
|
const prompt = mustache.render(target.prompt, lastOutput);
|
||||||
expr.registerFunction(
|
return { ok: true, value: { role: target.role, prompt } };
|
||||||
"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 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
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,
|
AgentAlias,
|
||||||
AgentConfig,
|
AgentConfig,
|
||||||
CasRef,
|
CasRef,
|
||||||
ConditionDefinition,
|
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
ModelConfig,
|
ModelConfig,
|
||||||
ModeratorContext,
|
ModeratorContext,
|
||||||
@@ -26,12 +25,12 @@ export type {
|
|||||||
StepNodePayload,
|
StepNodePayload,
|
||||||
StepOutput,
|
StepOutput,
|
||||||
StepRecord,
|
StepRecord,
|
||||||
|
Target,
|
||||||
ThreadForkOutput,
|
ThreadForkOutput,
|
||||||
ThreadId,
|
ThreadId,
|
||||||
ThreadListItem,
|
ThreadListItem,
|
||||||
ThreadStepsOutput,
|
ThreadStepsOutput,
|
||||||
ThreadsIndex,
|
ThreadsIndex,
|
||||||
Transition,
|
|
||||||
WorkflowConfig,
|
WorkflowConfig,
|
||||||
WorkflowName,
|
WorkflowName,
|
||||||
WorkflowPayload,
|
WorkflowPayload,
|
||||||
|
|||||||
@@ -14,22 +14,11 @@ const ROLE_DEFINITION: JSONSchema = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONDITION_DEFINITION: JSONSchema = {
|
const TARGET: JSONSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["description", "expression"],
|
required: ["role", "prompt"],
|
||||||
properties: {
|
|
||||||
description: { type: "string" },
|
|
||||||
expression: { type: "string" },
|
|
||||||
},
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TRANSITION: JSONSchema = {
|
|
||||||
type: "object",
|
|
||||||
required: ["role", "condition", "prompt"],
|
|
||||||
properties: {
|
properties: {
|
||||||
role: { type: "string" },
|
role: { type: "string" },
|
||||||
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
||||||
prompt: { type: "string" },
|
prompt: { type: "string" },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -38,7 +27,7 @@ const TRANSITION: JSONSchema = {
|
|||||||
export const WORKFLOW_SCHEMA: JSONSchema = {
|
export const WORKFLOW_SCHEMA: JSONSchema = {
|
||||||
title: "Workflow",
|
title: "Workflow",
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["name", "description", "roles", "conditions", "graph"],
|
required: ["name", "description", "roles", "graph"],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
@@ -46,15 +35,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
|
|||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: ROLE_DEFINITION,
|
additionalProperties: ROLE_DEFINITION,
|
||||||
},
|
},
|
||||||
conditions: {
|
|
||||||
type: "object",
|
|
||||||
additionalProperties: CONDITION_DEFINITION,
|
|
||||||
},
|
|
||||||
graph: {
|
graph: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
type: "array",
|
type: "object",
|
||||||
items: TRANSITION,
|
additionalProperties: TARGET,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,23 +27,16 @@ export type RoleDefinition = {
|
|||||||
frontmatter: CasRef;
|
frontmatter: CasRef;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Transition = {
|
export type Target = {
|
||||||
role: string;
|
role: string;
|
||||||
condition: string | null;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionDefinition = {
|
|
||||||
description: string;
|
|
||||||
expression: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowPayload = {
|
export type WorkflowPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, RoleDefinition>;
|
roles: Record<string, RoleDefinition>;
|
||||||
conditions: Record<string, ConditionDefinition>;
|
graph: Record<string, Record<string, Target>>;
|
||||||
graph: Record<string, Transition[]>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user