Compare commits

..

3 Commits

Author SHA1 Message Date
xiaoju 5a7f417899 feat: migrate examples to status-based routing + fix mustache HTML escape
- Migrate solve-issue.yaml, analyze-topic.yaml, debate.yaml to new format
- Add status enum field to all role frontmatter schemas
- Use {{{ }}} (triple mustache) for prompt templates with user content
- Disable mustache HTML escaping globally (prompts are plain text, not HTML)
- Add 2 new tests for HTML escape behavior
- 9 moderator tests pass

Phase 2 of #490 (closes #492)
2026-05-25 04:52:53 +00:00
xiaoju d00f9df2dd 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)
2026-05-25 04:50:06 +00:00
xiaoju ff959be3ef Merge pull request 'refactor(cli-workflow): reduce cmdStepRead cognitive complexity' (#488) from fix/487-refactor-step-read into main 2026-05-25 02:25:32 +00:00
14 changed files with 195 additions and 510 deletions
+5 -8
View File
@@ -22,6 +22,8 @@ roles:
frontmatter:
type: object
properties:
status:
enum: ["_"]
thesis:
type: string
keyPoints:
@@ -30,14 +32,9 @@ roles:
type: string
caveats:
type: string
required: [thesis, keyPoints]
conditions: {}
required: [status, thesis, keyPoints]
graph:
$START:
- role: "analyst"
condition: null
prompt: "Analyze the topic in the task and produce a structured summary with key points."
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
analyst:
- role: "$END"
condition: null
prompt: "Analysis complete. Finish the workflow."
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
+15 -30
View File
@@ -16,15 +16,16 @@ roles:
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
status:
enum: ["continue", "conceded"]
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
required: [status, argument]
for:
description: "Argues for the proposition"
goal: |
@@ -40,38 +41,22 @@ roles:
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
status:
enum: ["continue", "conceded"]
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
conditions:
againstConceded:
description: "The against side conceded"
expression: "$last('against').conceded = true"
forConceded:
description: "The for side conceded"
expression: "$last('for').conceded = true"
required: [status, argument]
graph:
$START:
- role: "against"
condition: null
prompt: "Present your opening argument against the proposition."
_: { role: "against", prompt: "Present your opening argument against the proposition." }
against:
- role: "$END"
condition: "againstConceded"
prompt: "The against side conceded. Debate over."
- role: "for"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
for:
- role: "$END"
condition: "forConceded"
prompt: "The for side conceded. Debate over."
- role: "against"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
+14 -24
View File
@@ -27,11 +27,13 @@ roles:
frontmatter:
type: object
properties:
status:
enum: ["_"]
repoPath:
type: string
plan:
type: string
required: [repoPath, plan]
required: [status, repoPath, plan]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
@@ -50,13 +52,15 @@ roles:
frontmatter:
type: object
properties:
status:
enum: ["_"]
filesChanged:
type: array
items:
type: string
summary:
type: string
required: [filesChanged, summary]
required: [status, filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
@@ -71,32 +75,18 @@ roles:
frontmatter:
type: object
properties:
approved:
type: boolean
status:
enum: ["approved", "rejected"]
comments:
type: string
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
required: [status, comments]
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
planner:
- role: "developer"
condition: null
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
developer:
- role: "reviewer"
condition: null
prompt: "Review the developer's implementation against the plan for correctness and quality."
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
reviewer:
- role: "developer"
condition: "notApproved"
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
- role: "$END"
condition: null
prompt: "The review passed. Complete the workflow."
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
@@ -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();
});
+34 -17
View File
@@ -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}`);
}
+16 -19
View File
@@ -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),
};
}
+4 -19
View File
@@ -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,122 @@
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: {
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",
},
const solveIssueGraph: WorkflowPayload["graph"] = {
$START: {
_: { role: "planner", prompt: "Start planning from the issue in the task." },
},
conditions: {
needsClarification: {
description: "Planner requests clarification from user",
expression: "$exists($last('planner').needsClarification)",
},
rejected: {
description: "Reviewer rejected the implementation",
expression: "$last('reviewer').approved = false",
},
planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
},
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." },
],
developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
},
reviewer: {
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: [
{
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([
{
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);
test("mustache does not HTML-escape prompt content", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
status: "rejected",
comments: 'use <T> & "Result<T, E>" types',
});
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Development failed; end." },
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
});
});
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.",
},
],
test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
},
};
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);
const result = evaluate(graph, "reviewer", {
status: "_",
comments: "<script>alert(1)</script>",
});
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "First plan was ready; end." },
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
});
});
test("$last returns undefined for unmatched role", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
hasReviewer: {
description: "Reviewer has run",
expression: "$exists($last('reviewer'))",
test("mustache template with nested object paths", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: {
role: "developer",
prompt: "Address: {{review.comments}}",
},
},
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
const result = evaluate(graph, "reviewer", {
status: "_",
review: { comments: "refactor the handler" },
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "No reviewer yet; implement." },
value: { role: "developer", prompt: "Address: refactor the handler" },
});
});
});
+2 -1
View File
@@ -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": {
+31 -102
View File
@@ -1,65 +1,42 @@
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";
// Disable HTML escaping — prompts are plain text, not HTML.
mustache.escape = (text: string) => text;
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 };
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;
}
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}"`),
};
}
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 +44,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}"`),
};
}
+1 -2
View File
@@ -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,
+5 -20
View File
@@ -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,
},
},
},
+2 -9
View File
@@ -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 节点 ─────────────────────────────────────────────────