feat(workflow-util-agent): prompt restructure + scope focus — Phase 3
- buildOutputFormatInstruction(schema): auto-generates frontmatter format guide from Zod schema, injected at top of system prompt - Adapter prepends deliverable format before role's systemPrompt - buildThreadInput reordered: Task → Steps → Parent → Tools - Scope reminder: 'Focus exclusively on YOUR role's deliverable' - 8 tests for buildOutputFormatInstruction Refs #351
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user