From e5ae9a134cb9c9e8f7821a22f937b1bcd3ccf33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 22 May 2026 06:29:56 +0000 Subject: [PATCH] feat: register $first/$last JSONata functions in moderator Register custom $first(role) and $last(role) functions in the JSONata evaluator. These search the steps array and return the matching role's frontmatter (output) directly, replacing verbose steps[-1].output.x expressions with semantic $last('role').field syntax. - workflow-moderator: register functions via expr.registerFunction() - Updated all condition expressions in .workflows/ and examples/ - Added tests for $last, $first, and unmatched role (undefined) Fixes #376 --- .workflows/solve-issue.yaml | 12 +- examples/solve-issue.yaml | 2 +- .../__tests__/evaluate.test.ts | 122 +++++++++++++++++- packages/workflow-moderator/src/evaluate.ts | 34 ++++- 4 files changed, 157 insertions(+), 13 deletions(-) diff --git a/.workflows/solve-issue.yaml b/.workflows/solve-issue.yaml index fddb1e1..cb78960 100644 --- a/.workflows/solve-issue.yaml +++ b/.workflows/solve-issue.yaml @@ -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" diff --git a/examples/solve-issue.yaml b/examples/solve-issue.yaml index 299b572..4e5d65b 100644 --- a/examples/solve-issue.yaml +++ b/examples/solve-issue.yaml @@ -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" diff --git a/packages/workflow-moderator/__tests__/evaluate.test.ts b/packages/workflow-moderator/__tests__/evaluate.test.ts index 54b6604..01fdd5d 100644 --- a/packages/workflow-moderator/__tests__/evaluate.test.ts +++ b/packages/workflow-moderator/__tests__/evaluate.test.ts @@ -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" }); + }); }); diff --git a/packages/workflow-moderator/src/evaluate.ts b/packages/workflow-moderator/src/evaluate.ts index 48cb7c7..a37c73e 100644 --- a/packages/workflow-moderator/src/evaluate.ts +++ b/packages/workflow-moderator/src/evaluate.ts @@ -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> { try { - const result = await jsonata(expression).evaluate(context); + const expr = jsonata(expression); + expr.registerFunction( + "first", + (role: string) => findByRole(context.steps, role, "first"), + "", + ); + expr.registerFunction( + "last", + (role: string) => findByRole(context.steps, role, "last"), + "", + ); + const result = await expr.evaluate(context); return { ok: true, value: result }; } catch (error) { return {