Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99a2890be2 | |||
| 2eb5ee0666 | |||
| e67932c83c | |||
| 04a12231c3 | |||
| e5ae9a134c | |||
| bdafaf3aa1 |
@@ -124,22 +124,22 @@ roles:
|
||||
conditions:
|
||||
insufficientInfo:
|
||||
description: "Planner determined there's not enough info to proceed"
|
||||
expression: "steps[-1].output.status = 'insufficient_info'"
|
||||
expression: "$last('planner').status = 'insufficient_info'"
|
||||
devFailed:
|
||||
description: "Developer failed to implement"
|
||||
expression: "steps[-1].output.status = 'failed'"
|
||||
expression: "$last('developer').status = 'failed'"
|
||||
rejected:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
fixCode:
|
||||
description: "Tester found code issues"
|
||||
expression: "steps[-1].output.status = 'fix_code'"
|
||||
expression: "$last('tester').status = 'fix_code'"
|
||||
fixSpec:
|
||||
description: "Tester found spec issues"
|
||||
expression: "steps[-1].output.status = 'fix_spec'"
|
||||
expression: "$last('tester').status = 'fix_spec'"
|
||||
hookFailed:
|
||||
description: "Push hook failed"
|
||||
expression: "steps[-1].output.success = false"
|
||||
expression: "$last('committer').success = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
|
||||
@@ -57,7 +57,7 @@ roles:
|
||||
conditions:
|
||||
notApproved:
|
||||
description: "Reviewer rejected the implementation"
|
||||
expression: "steps[-1].output.approved = false"
|
||||
expression: "$last('reviewer').approved = false"
|
||||
graph:
|
||||
$START:
|
||||
- role: "planner"
|
||||
|
||||
@@ -2,7 +2,12 @@ import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
import { putSchema, validate } from "@uncaged/json-cas";
|
||||
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type {
|
||||
CasRef,
|
||||
RoleDefinition,
|
||||
Transition,
|
||||
WorkflowPayload,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import {
|
||||
@@ -46,6 +51,18 @@ 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) => ({
|
||||
role: t.role,
|
||||
condition: t.condition ?? null,
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveFrontmatterRef(
|
||||
uwf: UwfStore,
|
||||
roleName: string,
|
||||
@@ -84,7 +101,7 @@ export async function materializeWorkflowPayload(
|
||||
description: raw.description,
|
||||
roles,
|
||||
conditions: raw.conditions,
|
||||
graph: raw.graph,
|
||||
graph: normalizeGraph(raw.graph),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,10 @@ function isTransition(value: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
const condition = value.condition;
|
||||
return typeof value.role === "string" && (condition === null || typeof condition === "string");
|
||||
return (
|
||||
typeof value.role === "string" &&
|
||||
(condition === null || condition === undefined || typeof condition === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
|
||||
|
||||
@@ -3,10 +3,9 @@ import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/workflow-protoc
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
|
||||
import { buildContextWithMeta } from "./context.js";
|
||||
import { extract } from "./extract.js";
|
||||
import { tryFrontmatterFastPath } from "./frontmatter.js";
|
||||
import type { AgentStore } from "./storage.js";
|
||||
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
|
||||
import { getEnvPath, resolveStorageRoot } from "./storage.js";
|
||||
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
|
||||
|
||||
function fail(message: string): never {
|
||||
@@ -73,24 +72,19 @@ async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<Agent
|
||||
async function extractOutput(
|
||||
rawOutput: string,
|
||||
outputSchema: CasRef,
|
||||
storageRoot: string,
|
||||
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>,
|
||||
): Promise<CasRef> {
|
||||
const fastPath = await runWithMessage("frontmatter fast path", () =>
|
||||
tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store),
|
||||
).catch(() => null);
|
||||
const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store);
|
||||
|
||||
if (fastPath !== null) {
|
||||
return fastPath.outputHash;
|
||||
}
|
||||
|
||||
const config = await runWithMessage("failed to load config", () =>
|
||||
loadWorkflowConfig(storageRoot),
|
||||
fail(
|
||||
"Agent output does not contain valid YAML frontmatter matching the role schema.\n" +
|
||||
"The agent must output a YAML frontmatter block (--- delimited) as the first thing in its response.\n" +
|
||||
`Raw output (first 500 chars): ${rawOutput.slice(0, 500)}`,
|
||||
);
|
||||
const extracted = await runWithMessage("extract failed", () =>
|
||||
extract(rawOutput, outputSchema, config),
|
||||
);
|
||||
return extracted.hash;
|
||||
}
|
||||
|
||||
async function persistStep(options: {
|
||||
@@ -136,12 +130,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||
}
|
||||
|
||||
const agentResult = await runAgent(options, ctx);
|
||||
const outputHash = await extractOutput(
|
||||
agentResult.output,
|
||||
roleDef.frontmatter,
|
||||
storageRoot,
|
||||
ctx,
|
||||
);
|
||||
const outputHash = await extractOutput(agentResult.output, roleDef.frontmatter, ctx);
|
||||
const stepHash = await persistStep({
|
||||
ctx,
|
||||
outputHash,
|
||||
|
||||
@@ -35,11 +35,11 @@ const solveIssueWorkflow: WorkflowPayload = {
|
||||
conditions: {
|
||||
needsClarification: {
|
||||
description: "Planner requests clarification from user",
|
||||
expression: "$exists(steps[-1].output.needsClarification)",
|
||||
expression: "$exists($last('planner').needsClarification)",
|
||||
},
|
||||
notApproved: {
|
||||
rejected: {
|
||||
description: "Reviewer rejected the implementation",
|
||||
expression: "steps[-1].output.approved = false",
|
||||
expression: "$last('reviewer').approved = false",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
@@ -50,7 +50,7 @@ const solveIssueWorkflow: WorkflowPayload = {
|
||||
],
|
||||
developer: [{ role: "reviewer", condition: null }],
|
||||
reviewer: [
|
||||
{ role: "developer", condition: "notApproved" },
|
||||
{ role: "developer", condition: "rejected" },
|
||||
{ role: "$END", condition: null },
|
||||
],
|
||||
},
|
||||
@@ -72,7 +72,7 @@ describe("evaluate", () => {
|
||||
expect(result).toEqual({ ok: true, value: "planner" });
|
||||
});
|
||||
|
||||
test("condition match (notApproved → developer)", async () => {
|
||||
test("condition match (rejected → developer)", async () => {
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "reviewer",
|
||||
@@ -126,4 +126,116 @@ describe("evaluate", () => {
|
||||
const result = await evaluate(solveIssueWorkflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "developer" });
|
||||
});
|
||||
|
||||
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 }],
|
||||
developer: [
|
||||
{ role: "$END", condition: "devFailed" },
|
||||
{ role: "reviewer", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
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);
|
||||
expect(result).toEqual({ ok: true, value: "$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 }],
|
||||
planner: [
|
||||
{ role: "$END", condition: "firstPlanReady" },
|
||||
{ role: "developer", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
const context = makeContext([
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "ready", plan: "ABC123" },
|
||||
detail: "7BQST3VW9F2MA",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "developer",
|
||||
output: { status: "done" },
|
||||
detail: "1VPBG9SM5E7WK",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
{
|
||||
role: "planner",
|
||||
output: { status: "revised", plan: "DEF456" },
|
||||
detail: "4RNMK6PX8B3WQ",
|
||||
agent: "uwf-hermes",
|
||||
},
|
||||
]);
|
||||
const result = await evaluate(workflow, context);
|
||||
expect(result).toEqual({ ok: true, value: "$END" });
|
||||
});
|
||||
|
||||
test("$last returns undefined for unmatched role", async () => {
|
||||
const workflow: WorkflowPayload = {
|
||||
...solveIssueWorkflow,
|
||||
conditions: {
|
||||
hasReviewer: {
|
||||
description: "Reviewer has run",
|
||||
expression: "$exists($last('reviewer'))",
|
||||
},
|
||||
},
|
||||
graph: {
|
||||
$START: [{ role: "planner", condition: null }],
|
||||
planner: [
|
||||
{ role: "$END", condition: "hasReviewer" },
|
||||
{ role: "developer", condition: null },
|
||||
],
|
||||
},
|
||||
};
|
||||
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: "developer" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,44 @@ function isTruthy(value: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function evaluateJsonata(
|
||||
expression: string,
|
||||
context: ModeratorContext,
|
||||
): Promise<Result<unknown, Error>> {
|
||||
try {
|
||||
const result = await jsonata(expression).evaluate(context);
|
||||
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 };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user