Merge pull request 'feat(workflow-util-agent): prompt restructure + scope focus — RFC #351 Phase 3' (#354) from feat/351-phase3-prompt-focus into main

This commit is contained in:
2026-05-19 05:57:37 +00:00
6 changed files with 189 additions and 17 deletions
@@ -0,0 +1,80 @@
import { describe, expect, test } from "vitest";
import * as z from "zod/v4";
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
describe("buildOutputFormatInstruction", () => {
test("always includes the frontmatter example block", () => {
const schema = z.object({ status: z.string() });
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("## Deliverable Format");
expect(result).toContain("status:");
expect(result).toContain("confidence:");
expect(result).toContain("artifacts:");
expect(result).toContain("scope:");
});
test("always includes scope reminder", () => {
const schema = z.object({ status: z.string() });
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("Focus exclusively on YOUR role's deliverable");
expect(result).toContain("Do not perform actions outside your role's scope");
});
test("lists fields from a flat ZodObject schema", () => {
const schema = z.object({
title: z.string(),
phases: z.array(z.string()),
reason: z.union([z.string(), z.null()]),
});
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("`title`");
expect(result).toContain("`phases`");
expect(result).toContain("`reason`");
});
test("lists union of fields from a discriminated union schema", () => {
const schema = z.discriminatedUnion("status", [
z.object({ status: z.literal("planned"), phases: z.array(z.string()) }),
z.object({ status: z.literal("aborted"), reason: z.string() }),
]);
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("`status`");
expect(result).toContain("`phases`");
expect(result).toContain("`reason`");
});
test("lists fields from a plain ZodUnion schema", () => {
const schema = z.union([
z.object({ kind: z.literal("a"), valueA: z.string() }),
z.object({ kind: z.literal("b"), valueB: z.number() }),
]);
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("`kind`");
expect(result).toContain("`valueA`");
expect(result).toContain("`valueB`");
});
test("falls back gracefully for a non-object schema (no field list crash)", () => {
const schema = z.string();
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("## Deliverable Format");
expect(result).toContain("schema fields will be extracted automatically");
});
test("marks frontmatter as the primary deliverable", () => {
const schema = z.object({ done: z.boolean() });
const result = buildOutputFormatInstruction(schema);
expect(result).toContain("primary deliverable");
});
test("no field is listed more than once for a union with overlapping keys", () => {
const schema = z.union([
z.object({ status: z.literal("a"), shared: z.string() }),
z.object({ status: z.literal("b"), shared: z.string() }),
]);
const result = buildOutputFormatInstruction(schema);
const matches = [...result.matchAll(/`shared`/g)];
expect(matches.length).toBe(1);
});
});
@@ -1,5 +1,7 @@
import { describe, expect, test, vi } from "vitest";
const mock = vi.fn;
import type { CasStore } from "@uncaged/workflow-cas";
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
@@ -3,30 +3,20 @@ import type { AgentContext, ThreadContext } from "@uncaged/workflow-runtime";
/**
* Builds a user-message string from thread context: task, previous steps, and tool hints.
* Does NOT include a system prompt — that is passed separately via the adapter.
*
* Ordering: Task → Previous Steps → Parent Context → Tools
* The "Deliverable" section lives in the system prompt (injected by createAgentAdapter).
*/
export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
const lines: string[] = [];
if (ctx.start.parentState !== null) {
lines.push("## Parent Context");
lines.push(
"This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " +
ctx.start.parentState,
);
lines.push(
`Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`,
);
lines.push("");
}
// 1. Task — what to do
lines.push("## Task");
lines.push(ctx.start.content);
const { steps } = ctx;
if (steps.length === 0) {
return lines.join("\n");
}
// 2. Context — previous steps
if (steps.length === 1) {
const s = steps[0];
lines.push("");
@@ -34,7 +24,7 @@ export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
lines.push("");
lines.push(`ContentHash: ${s.contentHash}`);
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
} else {
} else if (steps.length > 1) {
lines.push("");
lines.push("## Previous Steps");
for (let i = 0; i < steps.length - 1; i++) {
@@ -51,6 +41,24 @@ export async function buildThreadInput(ctx: ThreadContext): Promise<string> {
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
}
// 3. Parent context — available when this workflow was spawned by another
if (ctx.start.parentState !== null) {
lines.push("");
lines.push("## Parent Context");
lines.push(
"This workflow was spawned by a parent workflow. The parent's state at spawn time is available at hash: " +
ctx.start.parentState,
);
lines.push(
`Use \`uncaged-workflow cas get ${ctx.start.parentState}\` to inspect the parent's context and trace back through its steps.`,
);
}
if (steps.length === 0 && ctx.start.parentState === null) {
return lines.join("\n");
}
// 4. Tools — available commands
lines.push("");
lines.push("## Tools");
lines.push(
@@ -0,0 +1,79 @@
import type * as z from "zod/v4";
type ZodSchema = z.ZodType;
/**
* Extract the top-level field names from a Zod schema.
*
* Handles:
* - ZodObject → its `.shape` keys
* - ZodDiscriminatedUnion / ZodUnion → union of all variant shapes
*
* Returns an empty array for schemas that have no inspectable shape
* (e.g. primitives, ZodAny).
*/
function extractSchemaFields(schema: ZodSchema): string[] {
const def = schema.def as {
type: string;
shape?: Record<string, ZodSchema>;
options?: ZodSchema[];
};
if (def.type === "object" && def.shape !== undefined) {
return Object.keys(def.shape);
}
if ((def.type === "discriminated_union" || def.type === "union") && Array.isArray(def.options)) {
const fieldSet = new Set<string>();
for (const option of def.options) {
for (const field of extractSchemaFields(option as ZodSchema)) {
fieldSet.add(field);
}
}
return [...fieldSet];
}
return [];
}
/**
* Build a concise output format instruction block for an agent role.
*
* The instruction describes the expected frontmatter markdown format and lists
* the meta fields derived from `schema`. It is injected at the top of the
* system prompt so the deliverable format is the first thing the agent sees.
*
* Focus on YOUR role's deliverable. Do not perform actions outside your role's scope.
*/
export function buildOutputFormatInstruction(schema: ZodSchema): string {
const fields = extractSchemaFields(schema);
const fieldList =
fields.length > 0
? fields.map((f) => ` - \`${f}\``).join("\n")
: " (schema fields will be extracted automatically)";
return `## Deliverable Format
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
\`\`\`
---
status: done # done | needs_input | in_progress | failed
next: <role-name> # suggested next role, or omit
confidence: 0.9 # 0.0–1.0, your self-assessed confidence
artifacts: # list of file paths or CAS hashes you produced
- path/to/file.ts
scope: role # role | thread
---
... your markdown work here ...
\`\`\`
The frontmatter is the **primary deliverable** — the engine reads it directly.
Your meta output must satisfy these fields:
${fieldList}
Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope.`;
}
@@ -12,6 +12,7 @@ import {
validateFrontmatter,
} from "@uncaged/workflow-util";
import type * as z from "zod/v4";
import { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
const log = createLogger({ sink: { kind: "stderr" } });
@@ -83,8 +84,9 @@ export function createAgentAdapter<Opt>(
extract: ExtractOptionsFn<Opt>,
): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
const augmentedPrompt = `${buildOutputFormatInstruction(schema)}\n\n${prompt}`;
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const options = await extract(ctx, prompt, runtime);
const options = await extract(ctx, augmentedPrompt, runtime);
const raw = await agent(ctx, options);
const frontmatterResult = tryFrontmatterMeta(raw, schema);
@@ -1,4 +1,5 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export { buildOutputFormatInstruction } from "./build-output-format-instruction.js";
export { createAgentAdapter } from "./create-agent-adapter.js";
export type { SpawnCliError } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";