fix: remove _ single-exit for user roles #88

Merged
xiaoju merged 1 commits from fix/86-remove-single-exit-underscore into main 2026-06-05 02:09:50 +00:00
14 changed files with 158 additions and 138 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ roles:
type: object type: object
properties: properties:
$status: $status:
enum: ["_"] enum: ["done"]
thesis: thesis:
type: string type: string
keyPoints: keyPoints:
@@ -37,4 +37,4 @@ graph:
$START: $START:
_: { role: "analyst", 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: analyst:
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." } done: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
+30
View File
@@ -0,0 +1,30 @@
name: eval-simple
description: "Single-role eval workflow: fixer takes prompt, fixes code, done."
roles:
fixer:
description: "Fixes the code based on the prompt"
goal: |
You are a code fixer. Read the prompt, understand the bug, fix it, and verify by running the tests.
capabilities:
- code-editing
- test-running
procedure: |
1. Read the prompt to understand what needs to be fixed
2. Fix the bug in the source code
3. Run the tests mentioned in the prompt to verify
4. Output $status=done when tests pass
output: "Describe what you fixed and confirm tests pass. Set $status to done."
frontmatter:
type: object
properties:
$status:
type: string
enum: [done]
summary:
type: string
required: [$status, summary]
graph:
$START:
_: { role: "fixer", prompt: "Fix the code issue described in the task prompt." }
fixer:
done: { role: "$END", prompt: "Fix complete." }
+10 -10
View File
@@ -42,7 +42,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["done"] }
graph: graph:
$START: $START:
_: _:
@@ -59,7 +59,7 @@ graph:
prompt: "Try again" prompt: "Try again"
location: null location: null
roleB: roleB:
_: done:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -92,7 +92,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["done"] }
roleC: roleC:
description: Fail role description: Fail role
goal: Do C goal: Do C
@@ -104,7 +104,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["done"] }
graph: graph:
$START: $START:
_: _:
@@ -121,12 +121,12 @@ graph:
prompt: "Do C (fail)" prompt: "Do C (fail)"
location: null location: null
roleB: roleB:
_: done:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
roleC: roleC:
_: done:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -147,7 +147,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["done"] }
graph: graph:
$START: $START:
_: _:
@@ -155,7 +155,7 @@ graph:
prompt: "Work" prompt: "Work"
location: null location: null
worker: worker:
_: done:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -426,8 +426,8 @@ describe("currentRole field", () => {
await writeFile(wf, SINGLE_ROLE_WORKFLOW_YAML, "utf8"); await writeFile(wf, SINGLE_ROLE_WORKFLOW_YAML, "utf8");
const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir); const { thread } = await cmdThreadStart(storageRoot, wf, "test", tmpDir);
// worker → _ maps to $END // worker → done maps to $END
await insertStepNode(storageRoot, thread as ThreadId, "worker", {}); await insertStepNode(storageRoot, thread as ThreadId, "worker", { $status: "done" });
const result = await cmdThreadShow(storageRoot, thread as ThreadId); const result = await cmdThreadShow(storageRoot, thread as ThreadId);
expect(result.currentRole).toBe(null); expect(result.currentRole).toBe(null);
@@ -8,10 +8,10 @@ const solveIssueGraph: WorkflowPayload["graph"] = {
_: { role: "planner", prompt: "Start planning from the issue in the task.", location: null }, _: { role: "planner", prompt: "Start planning from the issue in the task.", location: null },
}, },
planner: { planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null }, planned: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null },
}, },
developer: { developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null }, implemented: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null },
}, },
reviewer: { reviewer: {
approved: { role: "$END", prompt: "Done.", location: null }, approved: { role: "$END", prompt: "Done.", location: null },
@@ -112,7 +112,7 @@ describe("evaluate", () => {
test("mustache template rendering with simple fields", () => { test("mustache template rendering with simple fields", () => {
const result = evaluate(solveIssueGraph, "planner", { const result = evaluate(solveIssueGraph, "planner", {
$status: "_", $status: "planned",
plan: "Add auth middleware", plan: "Add auth middleware",
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -139,11 +139,11 @@ describe("evaluate", () => {
test("triple mustache also works for unescaped output", () => { test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = { const graph: Record<string, Record<string, Target>> = {
reviewer: { reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}", location: null }, rejected: { role: "developer", prompt: "Fix: {{{comments}}}", location: null },
}, },
}; };
const result = evaluate(graph, "reviewer", { const result = evaluate(graph, "reviewer", {
$status: "_", $status: "rejected",
comments: "<script>alert(1)</script>", comments: "<script>alert(1)</script>",
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -152,24 +152,22 @@ describe("evaluate", () => {
}); });
}); });
test("missing $status defaults to _ (unit routing)", () => { test("missing $status → error (no unit fallback)", () => {
const result = evaluate(solveIssueGraph, "planner", { const result = evaluate(solveIssueGraph, "planner", {
plan: "Add auth middleware", plan: "Add auth middleware",
}); });
expect(result).toEqual({ expect(result.ok).toBe(false);
ok: true, if (!result.ok) {
value: { expect(result.error.message).toBe(
role: "developer", 'agent output for role "planner" is missing required "$status" string',
prompt: "Implement the plan: Add auth middleware", );
location: null, }
},
});
}); });
test("mustache template with nested object paths", () => { test("mustache template with nested object paths", () => {
const graph: Record<string, Record<string, Target>> = { const graph: Record<string, Record<string, Target>> = {
reviewer: { reviewer: {
_: { rejected: {
role: "developer", role: "developer",
prompt: "Address: {{review.comments}}", prompt: "Address: {{review.comments}}",
location: null, location: null,
@@ -177,7 +175,7 @@ describe("evaluate", () => {
}, },
}; };
const result = evaluate(graph, "reviewer", { const result = evaluate(graph, "reviewer", {
$status: "_", $status: "rejected",
review: { comments: "refactor the handler" }, review: { comments: "refactor the handler" },
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -254,7 +254,7 @@ describe("thread read timing", () => {
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "go", location: null } }, $START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "", location: null } }, worker: { done: { role: "$END", prompt: "", location: null } },
}, },
}); });
@@ -320,7 +320,7 @@ describe("thread read timing", () => {
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "go", location: null } }, $START: { _: { role: "worker", prompt: "go", location: null } },
worker: { _: { role: "$END", prompt: "", location: null } }, worker: { done: { role: "$END", prompt: "", location: null } },
}, },
}); });
@@ -54,7 +54,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["ready"] }
graph: graph:
$START: $START:
_: _:
@@ -62,7 +62,7 @@ graph:
prompt: "Plan the work" prompt: "Plan the work"
location: null location: null
planner: planner:
_: ready:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -110,7 +110,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["ready"] }
graph: graph:
$START: $START:
_: _:
@@ -118,7 +118,7 @@ graph:
prompt: "Plan" prompt: "Plan"
location: null location: null
planner: planner:
_: ready:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -153,7 +153,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["ready"] }
graph: graph:
$START: $START:
_: _:
@@ -161,7 +161,7 @@ graph:
prompt: "Plan" prompt: "Plan"
location: null location: null
planner: planner:
_: ready:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -79,7 +79,7 @@ async function setupSuspendedThread(mode: MockAgentMode): Promise<{
}, },
ok: { role: "reviewer", prompt: "Review the work", location: null }, ok: { role: "reviewer", prompt: "Review the work", location: null },
}, },
reviewer: { _: { role: "$END", prompt: "Done", location: null } }, reviewer: { done: { role: "$END", prompt: "Done", location: null } },
}, },
}); });
@@ -234,7 +234,7 @@ describe("uwf thread resume", () => {
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "Start", location: null } }, $START: { _: { role: "worker", prompt: "Start", location: null } },
worker: { _: { role: "$END", prompt: "Done", location: null } }, worker: { done: { role: "$END", prompt: "Done", location: null } },
}, },
}); });
@@ -480,8 +480,8 @@ describe("uwf thread resume - completed threads", () => {
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "Start work", location: null } }, $START: { _: { role: "worker", prompt: "Start work", location: null } },
worker: { _: { role: "reviewer", prompt: "Review the work", location: null } }, worker: { done: { role: "reviewer", prompt: "Review the work", location: null } },
reviewer: { _: { role: "$END", prompt: "Done", location: null } }, reviewer: { done: { role: "$END", prompt: "Done", location: null } },
}, },
}); });
@@ -493,8 +493,8 @@ describe("uwf thread resume - completed threads", () => {
process.env.OCAS_HOME = casDir; process.env.OCAS_HOME = casDir;
const workerOutputHash = await store.cas.put(outputSchemaHash, { $status: "_" }); const workerOutputHash = await store.cas.put(outputSchemaHash, { $status: "done" });
const reviewerOutputHash = await store.cas.put(outputSchemaHash, { $status: "_" }); const reviewerOutputHash = await store.cas.put(outputSchemaHash, { $status: "done" });
const detailHash = await store.cas.put(schemas.text, "mock detail"); const detailHash = await store.cas.put(schemas.text, "mock detail");
const workerStepHash = await store.cas.put(schemas.stepNode, { const workerStepHash = await store.cas.put(schemas.stepNode, {
@@ -563,7 +563,7 @@ describe("uwf thread resume - completed threads", () => {
stepHash: newWorkerStepHash, stepHash: newWorkerStepHash,
detailHash, detailHash,
role: "worker", role: "worker",
frontmatter: { $status: "_" }, frontmatter: { $status: "done" },
body: "", body: "",
startedAtMs: 1716600003000, startedAtMs: 1716600003000,
completedAtMs: 1716600004000, completedAtMs: 1716600004000,
@@ -641,7 +641,7 @@ echo '${adapterJson}'
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "Start", location: null } }, $START: { _: { role: "worker", prompt: "Start", location: null } },
worker: { _: { role: "$END", prompt: "Done", location: null } }, worker: { done: { role: "$END", prompt: "Done", location: null } },
}, },
}); });
@@ -689,7 +689,7 @@ echo '${adapterJson}'
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "Start", location: null } }, $START: { _: { role: "worker", prompt: "Start", location: null } },
worker: { _: { role: "$END", prompt: "Done", location: null } }, worker: { done: { role: "$END", prompt: "Done", location: null } },
}, },
}); });
@@ -31,7 +31,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["ready"] }
graph: graph:
$START: $START:
_: _:
@@ -39,7 +39,7 @@ graph:
prompt: "Plan the work" prompt: "Plan the work"
location: null location: null
planner: planner:
_: ready:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -54,7 +54,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string } $status: { type: string, enum: ["ready"] }
graph: graph:
$START: $START:
_: _:
@@ -62,7 +62,7 @@ graph:
prompt: "Plan the work" prompt: "Plan the work"
location: null location: null
planner: planner:
_: ready:
role: $END role: $END
prompt: "Done" prompt: "Done"
location: null location: null
@@ -17,7 +17,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { properties: {
$status: { enum: ["_"] }, $status: { enum: ["done"] },
plan: { type: "string" }, plan: { type: "string" },
}, },
required: ["$status", "plan"], required: ["$status", "plan"],
@@ -52,7 +52,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
}, },
graph: { graph: {
$START: { _: { role: "writer", prompt: "Begin writing", location: null } }, $START: { _: { role: "writer", prompt: "Begin writing", location: null } },
writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } }, writer: { done: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } },
reviewer: { reviewer: {
approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null }, approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null },
rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null }, rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null },
@@ -82,7 +82,7 @@ describe("Suite 1: Role Reference Integrity", () => {
output: "None", output: "None",
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { $status: { enum: ["_"] } }, properties: { $status: { enum: ["done"] } },
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
@@ -173,11 +173,11 @@ describe("Suite 2: Graph Structure", () => {
output: "Isolated", output: "Isolated",
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { $status: { enum: ["_"] } }, properties: { $status: { enum: ["done"] } },
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } }; wf.graph.isolated = { done: { role: "$END", prompt: "done", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe( expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe(
true, true,
@@ -186,34 +186,34 @@ describe("Suite 2: Graph Structure", () => {
test("2.6 edge target references invalid role", () => { test("2.6 edge target references invalid role", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } }; wf.graph.writer = { done: { role: "ghost", prompt: "Go to ghost", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true); expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true);
}); });
}); });
describe("Suite 3: Status-Edge Consistency", () => { describe("Suite 3: Status-Edge Consistency", () => {
test("3.1 single-exit role with multiple graph keys", () => { test("3.1 user role using _ graph key is rejected", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { wf.graph.writer = { _: { role: "reviewer", prompt: "Review", location: null } };
_: { role: "reviewer", prompt: "Review", location: null },
extra: { role: "$END", prompt: "Done", location: null },
};
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
errors.some((e) => errors.some((e) =>
e.includes('role "writer" is single-exit but has status keys other than "_"'), e.includes('role "writer" must use explicit $status keys in graph, not "_"'),
), ),
).toBe(true); ).toBe(true);
}); });
test("3.2 single-exit role missing _ key", () => { test("3.2 user role graph key not matching $status enum", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } }; wf.graph.writer = { wrong: { role: "reviewer", prompt: "Review", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(errors.some((e) => e.includes('role "writer" graph has extra status keys: wrong'))).toBe(
errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')), true,
).toBe(true); );
expect(errors.some((e) => e.includes('role "writer" graph is missing status keys: done'))).toBe(
true,
);
}); });
test("3.3 multi-exit role with extra statuses", () => { test("3.3 multi-exit role with extra statuses", () => {
@@ -244,9 +244,11 @@ describe("Suite 3: Status-Edge Consistency", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } }; wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe( expect(
true, errors.some((e) =>
); e.includes('role "reviewer" must use explicit $status keys in graph, not "_"'),
),
).toBe(true);
}); });
}); });
@@ -314,20 +316,20 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true); expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
}); });
test("3b.4 enum with single value (not multi-exit) treated as single-exit", () => { test("3b.4 enum with single explicit value passes", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.roles.writer = { wf.roles.writer = {
...wf.roles.writer, ...wf.roles.writer,
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { properties: {
$status: { enum: ["_"] }, $status: { enum: ["ready"] },
plan: { type: "string" }, plan: { type: "string" },
}, },
required: ["$status", "plan"], required: ["$status", "plan"],
} as unknown as string, } as unknown as string,
}; };
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } }; wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -355,13 +357,15 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
}); });
describe("Suite 4: Mustache Template Variable Existence", () => { describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.1 prompt references nonexistent variable (single-exit)", () => { test("4.1 prompt references nonexistent variable (enum status)", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } }; wf.graph.writer = {
done: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null },
};
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect( expect(
errors.some((e) => errors.some(
e.includes('prompt variable "branch" not found in role "writer" frontmatter'), (e) => e.includes('prompt variable "branch"') && e.includes('role "writer" frontmatter'),
), ),
).toBe(true); ).toBe(true);
}); });
@@ -388,7 +392,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => {
test("4.4 $status variable is always valid", () => { test("4.4 $status variable is always valid", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } }; wf.graph.writer = { done: { role: "reviewer", prompt: "Status: {{$status}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors).toEqual([]);
}); });
@@ -456,14 +460,14 @@ describe("Suite 6: Multiple Errors Collection", () => {
output: "None", output: "None",
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { $status: { enum: ["_"] } }, properties: { $status: { enum: ["done"] } },
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
// unknown graph reference // unknown graph reference
wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } }; wf.graph.nonexistent = { done: { role: "$END", prompt: "done", location: null } };
// bad mustache var // bad mustache var
wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } }; wf.graph.writer = { done: { role: "reviewer", prompt: "{{{badvar}}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.length).toBeGreaterThanOrEqual(3); expect(errors.length).toBeGreaterThanOrEqual(3);
}); });
@@ -31,7 +31,7 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { properties: {
$status: { type: "string" }, $status: { type: "string", enum: ["done"] },
}, },
required: ["$status"], required: ["$status"],
} as unknown as CasRef, } as unknown as CasRef,
@@ -39,7 +39,7 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
}, },
graph: { graph: {
$START: { _: { role: "worker", prompt: "start working", location: null } }, $START: { _: { role: "worker", prompt: "start working", location: null } },
worker: { _: { role: "$END", prompt: "done", location: null } }, worker: { done: { role: "$END", prompt: "done", location: null } },
}, },
}; };
} }
+13 -7
View File
@@ -8,7 +8,8 @@ mustache.escape = (text: string) => text;
const START_ROLE = "$START"; const START_ROLE = "$START";
const SUSPEND_ROLE = "$SUSPEND"; const SUSPEND_ROLE = "$SUSPEND";
const UNIT_STATUS = "_"; // $START is a special entry node with no agent output — it always uses this key.
const START_STATUS = "_";
type LastOutput = Record<string, unknown>; type LastOutput = Record<string, unknown>;
@@ -19,12 +20,17 @@ export function evaluate(
lastRole: string, lastRole: string,
lastOutput: LastOutput, lastOutput: LastOutput,
): Result<EvaluateResult, Error> { ): Result<EvaluateResult, Error> {
const status = let status: string;
lastRole === START_ROLE if (lastRole === START_ROLE) {
? UNIT_STATUS status = START_STATUS;
: typeof lastOutput[STATUS_KEY] === "string" } else if (typeof lastOutput[STATUS_KEY] === "string") {
? (lastOutput[STATUS_KEY] as string) status = lastOutput[STATUS_KEY] as string;
: UNIT_STATUS; } else {
return {
ok: false,
error: new Error(`agent output for role "${lastRole}" is missing required "$status" string`),
};
}
const roleTargets = graph[lastRole]; const roleTargets = graph[lastRole];
if (roleTargets === undefined) { if (roleTargets === undefined) {
+19 -46
View File
@@ -24,17 +24,13 @@ function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
return Array.isArray(obj.oneOf); return Array.isArray(obj.oneOf);
} }
/** Check if a frontmatter schema uses enum-based multi-exit ($status with multiple enum values). */ /** Check if a frontmatter schema declares "$status" as an enum (the required form for user roles). */
function isEnumMultiExit(fm: unknown): boolean { function hasStatusEnum(fm: unknown): boolean {
if (typeof fm !== "object" || fm === null) return false; if (typeof fm !== "object" || fm === null) return false;
const obj = fm as SchemaObj; const obj = fm as SchemaObj;
const props = obj.properties as Record<string, SchemaObj> | undefined; const props = obj.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return false; if (!props?.$status) return false;
const statusDef = props.$status; return Array.isArray(props.$status.enum);
if (!Array.isArray(statusDef.enum)) return false;
// Filter out "_" (wildcard) — if remaining values > 1, it's multi-exit
const statuses = (statusDef.enum as string[]).filter((s) => s !== "_");
return statuses.length > 1;
} }
/** Extract status values from an enum-based $status field. */ /** Extract status values from an enum-based $status field. */
@@ -43,7 +39,7 @@ function getEnumStatuses(fm: SchemaObj): string[] {
if (!props?.$status) return []; if (!props?.$status) return [];
const statusDef = props.$status; const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return []; if (!Array.isArray(statusDef.enum)) return [];
return (statusDef.enum as string[]).filter((s) => s !== "_"); return statusDef.enum as string[];
} }
/** Get property names from a schema object. */ /** Get property names from a schema object. */
@@ -194,15 +190,19 @@ function checkOneOfDiscriminant(
} }
} }
/** Check status-edge consistency for a multi-exit role. */ /** Check status-edge consistency for a user role. "_" is reserved for $START and rejected here. */
function checkMultiExitEdges( function checkStatusEdges(
roleName: string, roleName: string,
graphKeys: Set<string>, graphKeys: Set<string>,
statusSet: Set<string>, statusSet: Set<string>,
errors: string[], errors: string[],
): void { ): void {
if (graphKeys.has("_")) { if (graphKeys.has("_")) {
errors.push(`role "${roleName}" is multi-exit but graph uses "_"`); errors.push(`role "${roleName}" must use explicit $status keys in graph, not "_"`);
return;
}
if (statusSet.has("_")) {
errors.push(`role "${roleName}" $status enum must use explicit values, not "_"`);
return; return;
} }
@@ -255,50 +255,23 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
const statuses = getOneOfStatuses(variants); const statuses = getOneOfStatuses(variants);
checkOneOfDiscriminant(roleName, variants, statuses, errors); checkOneOfDiscriminant(roleName, variants, statuses, errors);
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors); checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
checkMultiExitMustache(roleName, graphEntry, variants, errors); checkMultiExitMustache(roleName, graphEntry, variants, errors);
} else if (isEnumMultiExit(fm)) { } else if (hasStatusEnum(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj); const statuses = getEnumStatuses(fm as SchemaObj);
checkMultiExitEdges(roleName, graphKeys, new Set(statuses), errors); checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
// For enum-based schemas, mustache vars come from the flat properties // For enum-based schemas, mustache vars come from the flat properties
checkSingleExitMustache(roleName, graphEntry, fm as SchemaObj, errors); checkEnumMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else { } else {
checkSingleExitRole(roleName, graphKeys, graphEntry, fm as SchemaObj | null, errors); errors.push(
} `role "${roleName}" must define "$status" as an enum (or oneOf const) in frontmatter`,
} );
}
/** Check single-exit role status and mustache. */
function checkSingleExitRole(
roleName: string,
graphKeys: Set<string>,
graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj | null,
errors: string[],
): void {
if (graphKeys.size > 1 || (graphKeys.size === 1 && !graphKeys.has("_"))) {
if (!graphKeys.has("_")) {
errors.push(`role "${roleName}" is single-exit but graph has no "_" key`);
} else {
errors.push(`role "${roleName}" is single-exit but has status keys other than "_"`);
}
}
const singleTarget = graphEntry._;
if (!singleTarget) return;
const vars = extractMustacheVars(singleTarget.prompt);
const propNames = fm ? getPropertyNames(fm) : new Set<string>();
for (const v of vars) {
if (v === "$status") continue;
if (!propNames.has(v)) {
errors.push(`prompt variable "${v}" not found in role "${roleName}" frontmatter`);
} }
} }
} }
/** Check mustache vars in all edge prompts against flat schema properties. */ /** Check mustache vars in all edge prompts against flat schema properties. */
function checkSingleExitMustache( function checkEnumMustache(
roleName: string, roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>, graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj, fm: SchemaObj,
+12 -3
View File
@@ -57,9 +57,18 @@ function isGraph(value: unknown): boolean {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
} }
return Object.values(value).every( return Object.entries(value).every(([node, statusMap]) => {
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)), if (!isRecord(statusMap)) {
); return false;
}
return Object.entries(statusMap).every(([status, target]) => {
// "_" is only valid as a status key for the $START entry node.
if (status === "_" && node !== "$START") {
return false;
}
return isTarget(target);
});
});
} }
/** /**