diff --git a/.workflows/solve-issue.yaml b/.workflows/solve-issue.yaml index 6f9297b..ae1aacf 100644 --- a/.workflows/solve-issue.yaml +++ b/.workflows/solve-issue.yaml @@ -154,25 +154,43 @@ conditions: graph: $START: - role: "planner" + condition: null + prompt: "Analyze the issue and produce an implementation plan." planner: - role: "$END" condition: "insufficientInfo" + prompt: "Insufficient information to proceed; end the workflow." - role: "developer" + condition: null + prompt: "Implement the plan from the planner." developer: - role: "$END" condition: "devFailed" + prompt: "Development failed; end the workflow." - role: "reviewer" + condition: null + prompt: "Send the implementation to the reviewer." reviewer: - role: "developer" condition: "rejected" + prompt: "Reviewer rejected the implementation; fix the issues." - role: "tester" + condition: null + prompt: "Review passed; run tests on the implementation." tester: - role: "developer" condition: "fixCode" + prompt: "Tests found code issues; return to developer." - role: "planner" condition: "fixSpec" + prompt: "Tests found spec issues; return to planner." - role: "committer" + condition: null + prompt: "Tests passed; commit and push the changes." committer: - role: "developer" condition: "hookFailed" + prompt: "Push hook failed; return to developer to fix." - role: "$END" + condition: null + prompt: "Commit succeeded; complete the workflow." diff --git a/examples/analyze-topic.yaml b/examples/analyze-topic.yaml index 9ddb143..118881a 100644 --- a/examples/analyze-topic.yaml +++ b/examples/analyze-topic.yaml @@ -36,6 +36,8 @@ graph: $START: - role: "analyst" condition: null + 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." diff --git a/examples/solve-issue.yaml b/examples/solve-issue.yaml index fd793e8..f286794 100644 --- a/examples/solve-issue.yaml +++ b/examples/solve-issue.yaml @@ -62,19 +62,19 @@ graph: $START: - role: "planner" condition: null - prompt: null + prompt: "Analyze the issue described in the task and produce a detailed implementation plan." planner: - role: "developer" condition: null - prompt: null + prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." developer: - role: "reviewer" condition: null - prompt: null + 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: null + prompt: "The review passed. Complete the workflow." diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index ed54c1e..d3db13c 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -624,12 +624,14 @@ function resolveAgentConfig( return agentConfig; } -function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string, edgePrompt: string | null): CasRef { +function spawnAgent( + agent: AgentConfig, + threadId: ThreadId, + role: string, + edgePrompt: string, +): CasRef { const argv = [...agent.args, threadId, role]; - const env = { ...process.env }; - if (edgePrompt !== null) { - env.UWF_EDGE_PROMPT = edgePrompt; - } + const env = { ...process.env, UWF_EDGE_PROMPT: edgePrompt }; let stdout: string; try { stdout = execFileSync(agent.command, argv, { diff --git a/packages/cli-workflow/src/commands/workflow.ts b/packages/cli-workflow/src/commands/workflow.ts index c0ec891..23c8368 100644 --- a/packages/cli-workflow/src/commands/workflow.ts +++ b/packages/cli-workflow/src/commands/workflow.ts @@ -55,11 +55,16 @@ function isJsonSchema(value: unknown): value is JSONSchema { function normalizeGraph(graph: Record): Record { const result: Record = {}; for (const [node, transitions] of Object.entries(graph)) { - result[node] = transitions.map((t) => ({ - role: t.role, - condition: t.condition ?? null, - prompt: t.prompt ?? null, - })); + 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)`); + } + return { + role: t.role, + condition: t.condition ?? null, + prompt: t.prompt, + }; + }); } return result; } diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index 6cffdef..61dffd2 100644 --- a/packages/cli-workflow/src/validate.ts +++ b/packages/cli-workflow/src/validate.ts @@ -44,6 +44,8 @@ function isTransition(value: unknown): boolean { const condition = value.condition; return ( typeof value.role === "string" && + typeof value.prompt === "string" && + value.prompt.trim() !== "" && (condition === null || condition === undefined || typeof condition === "string") ); } diff --git a/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts index 4e9f5ee..952252c 100644 --- a/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts +++ b/packages/workflow-agent-claude-code/__tests__/claude-code.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import type { AgentContext } from "@uncaged/workflow-agent-kit"; +import type { ThreadId } from "@uncaged/workflow-protocol"; import { buildClaudeCodePrompt } from "../src/claude-code.js"; function makeCtx(overrides: Partial = {}): AgentContext { return { + threadId: "01JTEST0000000000000000000" as ThreadId, + edgePrompt: "Proceed with the assigned role.", workflow: { roles: { developer: { diff --git a/packages/workflow-agent-hermes/src/hermes.ts b/packages/workflow-agent-hermes/src/hermes.ts index a1ba4c5..f65b777 100644 --- a/packages/workflow-agent-hermes/src/hermes.ts +++ b/packages/workflow-agent-hermes/src/hermes.ts @@ -50,7 +50,7 @@ function buildInitialPrompt(ctx: AgentContext): string { /** Assemble system prompt, task, and prior step outputs for Hermes. */ export function buildHermesPrompt(ctx: AgentContext): string { - if (ctx.edgePrompt !== null) { + if (ctx.edgePrompt !== "") { const parts: string[] = []; if (ctx.outputFormatInstruction !== "") { parts.push(ctx.outputFormatInstruction, ""); @@ -86,7 +86,7 @@ async function prepareSession( ctx: AgentContext, cwd: string, ): Promise { - if (ctx.edgePrompt === null || isResumeDisabled()) { + if (ctx.edgePrompt === "" || isResumeDisabled()) { await client.connect(cwd); return { useContinuation: false, resumed: false }; } @@ -127,7 +127,7 @@ export function createHermesAgent(): () => Promise { }); async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise { - const effectiveCtx = useContinuation ? ctx : { ...ctx, edgePrompt: null as string | null }; + const effectiveCtx = useContinuation ? ctx : { ...ctx, edgePrompt: "" }; const fullPrompt = buildHermesPrompt(effectiveCtx); const { text, sessionId, messages } = await client.prompt(fullPrompt); const { detailHash } = await storePromptResult(ctx.store, sessionId, messages); diff --git a/packages/workflow-agent-kit/src/context.ts b/packages/workflow-agent-kit/src/context.ts index bd4943d..2ea1a02 100644 --- a/packages/workflow-agent-kit/src/context.ts +++ b/packages/workflow-agent-kit/src/context.ts @@ -21,6 +21,14 @@ function fail(message: string): never { throw new Error(message); } +function readEdgePrompt(): string { + const value = process.env.UWF_EDGE_PROMPT; + if (value === undefined || value === "") { + fail("UWF_EDGE_PROMPT environment variable is required"); + } + return value; +} + function walkChain(store: Store, schemas: AgentStore["schemas"], headHash: CasRef): ChainState { const headNode = store.get(headHash); if (headNode === null) { @@ -133,7 +141,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise 0) { - graph["$START"] = [{ role: steps[0].role.name, condition: null }]; + const firstRole = steps[0].role.name; + graph["$START"] = [ + { + role: firstRole, + condition: null, + prompt: `Begin workflow at role ${firstRole}.`, + }, + ]; } return { name, description, roles, conditions, graph }; diff --git a/packages/workflow-moderator/__tests__/evaluate.test.ts b/packages/workflow-moderator/__tests__/evaluate.test.ts index 6cf4872..dcb7656 100644 --- a/packages/workflow-moderator/__tests__/evaluate.test.ts +++ b/packages/workflow-moderator/__tests__/evaluate.test.ts @@ -9,27 +9,27 @@ const solveIssueWorkflow: WorkflowPayload = { roles: { planner: { description: "Creates implementation plan", - identity: "You are a planning agent.", - prepare: "Review the issue context.", - execute: "Create a step-by-step plan.", - report: "Output the plan and steps.", - outputSchema: "5GWKR8TN1V3JA", + 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", - identity: "You are a developer agent.", - prepare: "Load coding tools.", - execute: "Implement the plan.", - report: "List files changed and summary.", - outputSchema: "8CNWT4KR6D1HV", + 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", - identity: "You are a code reviewer.", - prepare: "Review project conventions.", - execute: "Review the implementation.", - report: "Approve or reject with comments.", - outputSchema: "1VPBG9SM5E7WK", + goal: "You are a code reviewer.", + capabilities: ["code-review"], + procedure: "Review the implementation.", + output: "Approve or reject with comments.", + frontmatter: "1VPBG9SM5E7WK", }, }, conditions: { @@ -43,15 +43,35 @@ const solveIssueWorkflow: WorkflowPayload = { }, }, graph: { - $START: [{ role: "planner", condition: null }], - planner: [ - { role: "developer", condition: "needsClarification" }, - { role: "$END", condition: null }, + $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.", + }, ], - developer: [{ role: "reviewer", condition: null }], reviewer: [ - { role: "developer", condition: "rejected" }, - { role: "$END", condition: null }, + { + role: "developer", + condition: "rejected", + prompt: "Reviewer rejected; return to developer.", + }, + { role: "$END", condition: null, prompt: "Review passed; end workflow." }, ], }, }; @@ -69,7 +89,10 @@ function makeContext(steps: ModeratorContext["steps"]): ModeratorContext { describe("evaluate", () => { test("$START → first role (fallback)", async () => { const result = await evaluate(solveIssueWorkflow, makeContext([])); - expect(result).toEqual({ ok: true, value: { role: "planner", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "planner", prompt: "Start planning from the issue in the task." }, + }); }); test("condition match (rejected → developer)", async () => { @@ -82,7 +105,10 @@ describe("evaluate", () => { }, ]); const result = await evaluate(solveIssueWorkflow, context); - expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "developer", prompt: "Reviewer rejected; return to developer." }, + }); }); test("fallback when condition does not match → $END", async () => { @@ -95,7 +121,10 @@ describe("evaluate", () => { }, ]); const result = await evaluate(solveIssueWorkflow, context); - expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "$END", prompt: "Review passed; end workflow." }, + }); }); test("missing role in graph → error", async () => { @@ -124,7 +153,10 @@ describe("evaluate", () => { }, ]); const result = await evaluate(solveIssueWorkflow, context); - expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "developer", prompt: "Clarification is needed; hand off to developer." }, + }); }); test("$last returns most recent matching role's frontmatter", async () => { @@ -137,10 +169,20 @@ describe("evaluate", () => { }, }, graph: { - $START: [{ role: "developer", condition: null }], + $START: [ + { + role: "developer", + condition: null, + prompt: "Begin development.", + }, + ], developer: [ - { role: "$END", condition: "devFailed" }, - { role: "reviewer", condition: null }, + { role: "$END", condition: "devFailed", prompt: "Development failed; end." }, + { + role: "reviewer", + condition: null, + prompt: "Development succeeded; review.", + }, ], }, }; @@ -165,7 +207,10 @@ describe("evaluate", () => { }, ]); const result = await evaluate(workflow, context); - expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "$END", prompt: "Development failed; end." }, + }); }); test("$first returns earliest matching role's frontmatter", async () => { @@ -178,10 +223,20 @@ describe("evaluate", () => { }, }, graph: { - $START: [{ role: "planner", condition: null }], + $START: [ + { + role: "planner", + condition: null, + prompt: "Begin planning.", + }, + ], planner: [ - { role: "$END", condition: "firstPlanReady" }, - { role: "developer", condition: null }, + { role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." }, + { + role: "developer", + condition: null, + prompt: "Plan not ready on first pass; implement.", + }, ], }, }; @@ -206,7 +261,10 @@ describe("evaluate", () => { }, ]); const result = await evaluate(workflow, context); - expect(result).toEqual({ ok: true, value: { role: "$END", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "$END", prompt: "First plan was ready; end." }, + }); }); test("$last returns undefined for unmatched role", async () => { @@ -219,10 +277,20 @@ describe("evaluate", () => { }, }, graph: { - $START: [{ role: "planner", condition: null }], + $START: [ + { + role: "planner", + condition: null, + prompt: "Begin planning.", + }, + ], planner: [ - { role: "$END", condition: "hasReviewer" }, - { role: "developer", condition: null }, + { role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." }, + { + role: "developer", + condition: null, + prompt: "No reviewer yet; implement.", + }, ], }, }; @@ -236,6 +304,9 @@ describe("evaluate", () => { ]); const result = await evaluate(workflow, context); // no reviewer step → $exists returns false → fallback to developer - expect(result).toEqual({ ok: true, value: { role: "developer", prompt: null } }); + expect(result).toEqual({ + ok: true, + value: { role: "developer", prompt: "No reviewer yet; implement." }, + }); }); }); diff --git a/packages/workflow-moderator/src/evaluate.ts b/packages/workflow-moderator/src/evaluate.ts index c2ffa9a..a515cd0 100644 --- a/packages/workflow-moderator/src/evaluate.ts +++ b/packages/workflow-moderator/src/evaluate.ts @@ -90,7 +90,7 @@ export async function evaluate( for (const transition of transitions) { if (transition.condition === null) { - return { ok: true, value: { role: transition.role, prompt: transition.prompt ?? null } }; + return { ok: true, value: { role: transition.role, prompt: transition.prompt } }; } const conditionDef = workflow.conditions[transition.condition]; @@ -106,7 +106,7 @@ export async function evaluate( return evalResult; } if (isTruthy(evalResult.value)) { - return { ok: true, value: { role: transition.role, prompt: transition.prompt ?? null } }; + return { ok: true, value: { role: transition.role, prompt: transition.prompt } }; } } diff --git a/packages/workflow-moderator/src/types.ts b/packages/workflow-moderator/src/types.ts index 0d09297..77599f0 100644 --- a/packages/workflow-moderator/src/types.ts +++ b/packages/workflow-moderator/src/types.ts @@ -1,7 +1,7 @@ export type Result = { ok: true; value: T } | { ok: false; error: E }; -/** The result of moderator evaluation — which role to go to, and the edge prompt (if any). */ +/** The result of moderator evaluation — which role to go to, and the edge prompt. */ export type EvaluateResult = { role: string; - prompt: string | null; + prompt: string; }; diff --git a/packages/workflow-protocol/src/schemas.ts b/packages/workflow-protocol/src/schemas.ts index cb28471..09ed1aa 100644 --- a/packages/workflow-protocol/src/schemas.ts +++ b/packages/workflow-protocol/src/schemas.ts @@ -26,10 +26,11 @@ const CONDITION_DEFINITION: JSONSchema = { const TRANSITION: JSONSchema = { type: "object", - required: ["role", "condition"], + required: ["role", "condition", "prompt"], properties: { role: { type: "string" }, condition: { anyOf: [{ type: "string" }, { type: "null" }] }, + prompt: { type: "string" }, }, additionalProperties: false, }; diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 86b1a09..1dcd822 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -28,7 +28,7 @@ export type RoleDefinition = { export type Transition = { role: string; condition: string | null; - prompt: string | null; + prompt: string; }; export type ConditionDefinition = {