diff --git a/packages/workflow-util-agent/__tests__/build-output-format-instruction.test.ts b/packages/workflow-util-agent/__tests__/build-output-format-instruction.test.ts new file mode 100644 index 0000000..ffe8118 --- /dev/null +++ b/packages/workflow-util-agent/__tests__/build-output-format-instruction.test.ts @@ -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); + }); +}); diff --git a/packages/workflow-util-agent/__tests__/create-agent-adapter.test.ts b/packages/workflow-util-agent/__tests__/create-agent-adapter.test.ts index 83ad04e..80c0d22 100644 --- a/packages/workflow-util-agent/__tests__/create-agent-adapter.test.ts +++ b/packages/workflow-util-agent/__tests__/create-agent-adapter.test.ts @@ -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"; diff --git a/packages/workflow-util-agent/src/build-agent-prompt.ts b/packages/workflow-util-agent/src/build-agent-prompt.ts index 9d217c0..8522d25 100644 --- a/packages/workflow-util-agent/src/build-agent-prompt.ts +++ b/packages/workflow-util-agent/src/build-agent-prompt.ts @@ -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 { 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 { 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 { 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( diff --git a/packages/workflow-util-agent/src/build-output-format-instruction.ts b/packages/workflow-util-agent/src/build-output-format-instruction.ts new file mode 100644 index 0000000..c99131a --- /dev/null +++ b/packages/workflow-util-agent/src/build-output-format-instruction.ts @@ -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; + 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(); + 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: # 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.`; +} diff --git a/packages/workflow-util-agent/src/create-agent-adapter.ts b/packages/workflow-util-agent/src/create-agent-adapter.ts index 9825289..e4d66d1 100644 --- a/packages/workflow-util-agent/src/create-agent-adapter.ts +++ b/packages/workflow-util-agent/src/create-agent-adapter.ts @@ -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( extract: ExtractOptionsFn, ): AdapterFn { return (prompt: string, schema: z.ZodType) => { + const augmentedPrompt = `${buildOutputFormatInstruction(schema)}\n\n${prompt}`; return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise> => { - 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); diff --git a/packages/workflow-util-agent/src/index.ts b/packages/workflow-util-agent/src/index.ts index 7a75ce3..93479ef 100644 --- a/packages/workflow-util-agent/src/index.ts +++ b/packages/workflow-util-agent/src/index.ts @@ -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";