feat: make edge prompt required (Phase 1)

- Transition.prompt: string | null → string
- EvaluateResult.prompt: string | null → string
- AgentContext.edgePrompt: string | null → string
- CLI YAML validation rejects missing prompt
- All tests updated

Phase 2 will replace edgePrompt === null checks with findLastRoleIndex.

Refs #405, #406, #404
This commit is contained in:
2026-05-23 04:28:47 +00:00
parent b9258f84a5
commit 3d6399c0e3
16 changed files with 189 additions and 69 deletions
+18
View File
@@ -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."
+2
View File
@@ -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."
+4 -4
View File
@@ -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."
+7 -5
View File
@@ -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, {
+10 -5
View File
@@ -55,11 +55,16 @@ function isJsonSchema(value: unknown): value is JSONSchema {
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,
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;
}
+2
View File
@@ -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")
);
}
@@ -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> = {}): AgentContext {
return {
threadId: "01JTEST0000000000000000000" as ThreadId,
edgePrompt: "Proceed with the assigned role.",
workflow: {
roles: {
developer: {
+3 -3
View File
@@ -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<PromptAttempt> {
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<void> {
});
async function runPrompt(ctx: AgentContext, useContinuation: boolean): Promise<AgentRunResult> {
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);
+10 -2
View File
@@ -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<Ag
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
const edgePrompt = process.env.UWF_EDGE_PROMPT ?? null;
const edgePrompt = readEdgePrompt();
return {
threadId,
@@ -180,7 +188,7 @@ export async function buildContextWithMeta(
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
const edgePrompt = process.env.UWF_EDGE_PROMPT ?? null;
const edgePrompt = readEdgePrompt();
return {
threadId,
+3 -4
View File
@@ -13,11 +13,10 @@ export type AgentContext = ModeratorContext & {
*/
outputFormatInstruction: string;
/**
* Edge prompt from the graph transition that led to this role.
* null on first entry (use full role definition), non-null on re-entry
* (use as continuation instruction from moderator).
* Edge prompt from the graph transition that led to this role (UWF_EDGE_PROMPT).
* Phase 2 will use visit history to choose full role definition vs continuation.
*/
edgePrompt: string | null;
edgePrompt: string;
};
export type AgentRunResult = {
+11 -2
View File
@@ -77,9 +77,11 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
};
}
}
const targetRole = t.target === "END" ? "$END" : t.target;
return {
role: t.target === "END" ? "$END" : t.target,
role: targetRole,
condition: condName,
prompt: `Transition to ${targetRole}.`,
};
});
@@ -87,7 +89,14 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
}
if (steps.length > 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 };
@@ -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." },
});
});
});
+2 -2
View File
@@ -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 } };
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
export type Result<T, E> = { 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;
};
+2 -1
View File
@@ -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,
};
+1 -1
View File
@@ -28,7 +28,7 @@ export type RoleDefinition = {
export type Transition = {
role: string;
condition: string | null;
prompt: string | null;
prompt: string;
};
export type ConditionDefinition = {