From 892ccab8d553102d63baef8e434ef5a58f01ab22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Tue, 19 May 2026 06:20:15 +0000 Subject: [PATCH] feat(uwf-agent-kit): frontmatter fast path + prompt injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port RFC #351 frontmatter markdown to uwf-* path: - tryFrontmatterFastPath(): parse → validate → JSON Schema check via json-cas - Happy path skips LLM extract, fallback to existing extract() - buildOutputFormatInstruction(): generates deliverable format from JSON Schema - Injected into agent context before execution - Scope reminder: 'Focus exclusively on YOUR role's deliverable' - 14 new tests (vitest) Closes #355 --- .../build-output-format-instruction.test.ts | 89 ++++++++++++ .../__tests__/frontmatter-fast-path.test.ts | 136 ++++++++++++++++++ packages/uwf-agent-kit/package.json | 1 + .../src/build-output-format-instruction.ts | 75 ++++++++++ packages/uwf-agent-kit/src/context.ts | 2 + packages/uwf-agent-kit/src/frontmatter.ts | 61 ++++++++ packages/uwf-agent-kit/src/index.ts | 3 + packages/uwf-agent-kit/src/run.ts | 20 ++- packages/uwf-agent-kit/src/types.ts | 6 + 9 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 packages/uwf-agent-kit/__tests__/build-output-format-instruction.test.ts create mode 100644 packages/uwf-agent-kit/__tests__/frontmatter-fast-path.test.ts create mode 100644 packages/uwf-agent-kit/src/build-output-format-instruction.ts create mode 100644 packages/uwf-agent-kit/src/frontmatter.ts diff --git a/packages/uwf-agent-kit/__tests__/build-output-format-instruction.test.ts b/packages/uwf-agent-kit/__tests__/build-output-format-instruction.test.ts new file mode 100644 index 0000000..31e4680 --- /dev/null +++ b/packages/uwf-agent-kit/__tests__/build-output-format-instruction.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "vitest"; + +import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js"; + +describe("buildOutputFormatInstruction", () => { + test("always includes the frontmatter example block", () => { + const result = buildOutputFormatInstruction({}); + expect(result).toContain("---"); + expect(result).toContain("status: done"); + expect(result).toContain("confidence:"); + expect(result).toContain("scope: role"); + }); + + test("always marks frontmatter as the primary deliverable", () => { + const result = buildOutputFormatInstruction({}); + expect(result).toContain("primary deliverable"); + }); + + test("lists fields from a flat object schema", () => { + const schema = { + type: "object", + properties: { + status: { type: "string" }, + confidence: { type: "number" }, + }, + }; + const result = buildOutputFormatInstruction(schema); + expect(result).toContain("`status`"); + expect(result).toContain("`confidence`"); + }); + + test("lists union of fields from an anyOf schema", () => { + const schema = { + anyOf: [ + { + type: "object", + properties: { alpha: { type: "string" } }, + }, + { + type: "object", + properties: { beta: { type: "number" } }, + }, + ], + }; + const result = buildOutputFormatInstruction(schema); + expect(result).toContain("`alpha`"); + expect(result).toContain("`beta`"); + }); + + test("lists union of fields from a oneOf schema", () => { + const schema = { + oneOf: [ + { + type: "object", + properties: { foo: { type: "string" } }, + }, + { + type: "object", + properties: { bar: { type: "boolean" } }, + }, + ], + }; + const result = buildOutputFormatInstruction(schema); + expect(result).toContain("`foo`"); + expect(result).toContain("`bar`"); + }); + + test("falls back gracefully for a non-object schema with no properties", () => { + const result = buildOutputFormatInstruction({ type: "string" }); + expect(result).toContain("schema fields will be extracted automatically"); + }); + + test("does not list a field more than once for a union with overlapping keys", () => { + const schema = { + anyOf: [ + { type: "object", properties: { shared: { type: "string" } } }, + { type: "object", properties: { shared: { type: "number" } } }, + ], + }; + const result = buildOutputFormatInstruction(schema); + const matches = [...result.matchAll(/`shared`/g)]; + expect(matches.length).toBe(1); + }); + + test("includes focus reminder about role scope", () => { + const result = buildOutputFormatInstruction({}); + expect(result).toContain("Focus exclusively on YOUR role"); + }); +}); diff --git a/packages/uwf-agent-kit/__tests__/frontmatter-fast-path.test.ts b/packages/uwf-agent-kit/__tests__/frontmatter-fast-path.test.ts new file mode 100644 index 0000000..2b9880d --- /dev/null +++ b/packages/uwf-agent-kit/__tests__/frontmatter-fast-path.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "vitest"; + +import { createMemoryStore, putSchema } from "@uncaged/json-cas"; + +import { tryFrontmatterFastPath } from "../src/frontmatter.js"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** JSON Schema that exactly matches the AgentFrontmatter fields. */ +const FRONTMATTER_SCHEMA = { + type: "object", + properties: { + status: { anyOf: [{ type: "string" }, { type: "null" }] }, + next: { anyOf: [{ type: "string" }, { type: "null" }] }, + confidence: { anyOf: [{ type: "number" }, { type: "null" }] }, + artifacts: { type: "array", items: { type: "string" } }, + scope: { type: "string" }, + }, + required: ["status", "next", "confidence", "artifacts", "scope"], + additionalProperties: false, +}; + +/** JSON Schema that requires a non-frontmatter field — fast path must not satisfy it. */ +const STRICT_SCHEMA = { + type: "object", + properties: { + requiredField: { type: "string" }, + }, + required: ["requiredField"], + additionalProperties: false, +}; + +async function makeStoreWithSchema(schema: Record) { + const store = createMemoryStore(); + const schemaHash = await putSchema(store, schema); + return { store, schemaHash }; +} + +// ── Happy path ───────────────────────────────────────────────────────────────── + +describe("tryFrontmatterFastPath — happy path", () => { + test("parses valid frontmatter and returns outputHash + stripped body", async () => { + const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); + + const raw = [ + "---", + "status: done", + "next: reviewer", + "confidence: 0.9", + "artifacts: [src/foo.ts]", + "scope: role", + "---", + "", + "## Summary", + "Work is complete.", + ].join("\n"); + + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + + expect(result).not.toBeNull(); + expect(result?.body).toContain("## Summary"); + expect(result?.body).toContain("Work is complete."); + expect(result?.body).not.toContain("status: done"); + expect(typeof result?.outputHash).toBe("string"); + expect((result?.outputHash ?? "").length).toBeGreaterThan(0); + }); + + test("stored CAS node payload matches frontmatter fields", async () => { + const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); + + const raw = "---\nstatus: done\nnext: null\nconfidence: null\nartifacts: []\nscope: role\n---\n\nBody."; + + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + expect(result).not.toBeNull(); + + const node = store.get(result!.outputHash); + expect(node).not.toBeNull(); + const payload = node!.payload as Record; + expect(payload.status).toBe("done"); + expect(payload.next).toBeNull(); + expect(payload.confidence).toBeNull(); + expect(payload.artifacts).toEqual([]); + expect(payload.scope).toBe("role"); + }); +}); + +// ── Fallback: no frontmatter ─────────────────────────────────────────────────── + +describe("tryFrontmatterFastPath — fallback: no frontmatter", () => { + test("returns null for plain markdown without frontmatter block", async () => { + const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); + + const result = await tryFrontmatterFastPath( + "This is plain markdown without any frontmatter.", + schemaHash, + store, + ); + + expect(result).toBeNull(); + }); +}); + +// ── Fallback: invalid frontmatter ───────────────────────────────────────────── + +describe("tryFrontmatterFastPath — fallback: invalid frontmatter", () => { + test("returns null when confidence is out of range [0, 1]", async () => { + const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); + + const raw = "---\nstatus: done\nconfidence: 1.5\nscope: role\n---\n\nBody."; + + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + expect(result).toBeNull(); + }); + + test("returns null when next contains whitespace", async () => { + const { store, schemaHash } = await makeStoreWithSchema(FRONTMATTER_SCHEMA); + + const raw = "---\nstatus: done\nnext: some role\nscope: role\n---\n\nBody."; + + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + expect(result).toBeNull(); + }); +}); + +// ── Fallback: schema mismatch ───────────────────────────────────────────────── + +describe("tryFrontmatterFastPath — fallback: schema mismatch", () => { + test("returns null when outputSchema requires fields not in frontmatter", async () => { + const { store, schemaHash } = await makeStoreWithSchema(STRICT_SCHEMA); + + const raw = "---\nstatus: done\nscope: role\n---\n\nBody."; + + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + expect(result).toBeNull(); + }); +}); diff --git a/packages/uwf-agent-kit/package.json b/packages/uwf-agent-kit/package.json index e747d8a..f588ce0 100644 --- a/packages/uwf-agent-kit/package.json +++ b/packages/uwf-agent-kit/package.json @@ -21,6 +21,7 @@ "@uncaged/json-cas": "^0.3.0", "@uncaged/json-cas-fs": "^0.3.0", "@uncaged/uwf-protocol": "workspace:^", + "@uncaged/workflow-util": "workspace:^", "dotenv": "^16.6.1", "yaml": "^2.8.4" }, diff --git a/packages/uwf-agent-kit/src/build-output-format-instruction.ts b/packages/uwf-agent-kit/src/build-output-format-instruction.ts new file mode 100644 index 0000000..5ecb381 --- /dev/null +++ b/packages/uwf-agent-kit/src/build-output-format-instruction.ts @@ -0,0 +1,75 @@ +import type { JSONSchema } from "@uncaged/json-cas"; + +/** + * Extract top-level property names from a JSON Schema object. + * + * Handles: + * - Object schemas with a `properties` key + * - Union schemas via `anyOf` / `oneOf` — union of all variant property names + * + * Returns an empty array for schemas with no inspectable property definitions. + */ +function extractSchemaFields(schema: JSONSchema): string[] { + if (typeof schema.properties === "object" && schema.properties !== null) { + return Object.keys(schema.properties as Record); + } + + const unionKey = Array.isArray(schema.anyOf) + ? "anyOf" + : Array.isArray(schema.oneOf) + ? "oneOf" + : null; + + if (unionKey !== null) { + const variants = schema[unionKey] as JSONSchema[]; + const fieldSet = new Set(); + for (const variant of variants) { + for (const field of extractSchemaFields(variant)) { + 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 the JSON Schema. It is prepended to the agent's + * system prompt so the deliverable format is the first thing the agent sees. + */ +export function buildOutputFormatInstruction(schema: JSONSchema): 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/uwf-agent-kit/src/context.ts b/packages/uwf-agent-kit/src/context.ts index df3f5d5..8e83e2c 100644 --- a/packages/uwf-agent-kit/src/context.ts +++ b/packages/uwf-agent-kit/src/context.ts @@ -152,6 +152,7 @@ export async function buildContext(threadId: ThreadId, role: string): Promise { + const { frontmatter, body } = parseFrontmatterMarkdown(raw); + + if (frontmatter === null) { + return null; + } + + const validationErrors = validateFrontmatter(frontmatter); + if (validationErrors.length > 0) { + return null; + } + + const candidate: Record = { + status: frontmatter.status, + next: frontmatter.next, + confidence: frontmatter.confidence, + artifacts: [...frontmatter.artifacts], + scope: frontmatter.scope, + }; + + let outputHash: CasRef; + let node: ReturnType; + + try { + outputHash = await store.put(outputSchema, candidate); + node = store.get(outputHash); + } catch { + return null; + } + + if (node === null || !validate(store, node)) { + return null; + } + + return { body, outputHash }; +} diff --git a/packages/uwf-agent-kit/src/index.ts b/packages/uwf-agent-kit/src/index.ts index cd3b33f..2d5f6b0 100644 --- a/packages/uwf-agent-kit/src/index.ts +++ b/packages/uwf-agent-kit/src/index.ts @@ -6,6 +6,9 @@ export { resolveExtractModelAlias, resolveModel, } from "./extract.js"; +export { buildOutputFormatInstruction } from "./build-output-format-instruction.js"; +export type { FrontmatterFastPathResult } from "./frontmatter.js"; +export { tryFrontmatterFastPath } from "./frontmatter.js"; export { createAgent } from "./run.js"; export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js"; export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js"; diff --git a/packages/uwf-agent-kit/src/run.ts b/packages/uwf-agent-kit/src/run.ts index 3abc69d..bd229fb 100644 --- a/packages/uwf-agent-kit/src/run.ts +++ b/packages/uwf-agent-kit/src/run.ts @@ -1,9 +1,11 @@ -import { validate } from "@uncaged/json-cas"; +import { getSchema, validate } from "@uncaged/json-cas"; import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol"; import { config as loadDotenv } from "dotenv"; import { buildContextWithMeta } from "./context.js"; +import { buildOutputFormatInstruction } from "./build-output-format-instruction.js"; import { extract } from "./extract.js"; +import { tryFrontmatterFastPath } from "./frontmatter.js"; import type { AgentStore } from "./storage.js"; import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js"; @@ -73,7 +75,16 @@ async function extractOutput( rawOutput: string, outputSchema: CasRef, storageRoot: string, + ctx: Awaited>, ): Promise { + const fastPath = await runWithMessage("frontmatter fast path", () => + tryFrontmatterFastPath(rawOutput, outputSchema, ctx.meta.store), + ).catch(() => null); + + if (fastPath !== null) { + return fastPath.outputHash; + } + const config = await runWithMessage("failed to load config", () => loadWorkflowConfig(storageRoot), ); @@ -120,8 +131,13 @@ export function createAgent(options: AgentOptions): () => Promise { fail(`unknown role: ${role}`); } + const outputSchema = getSchema(ctx.meta.store, roleDef.outputSchema); + if (outputSchema !== null) { + ctx.outputFormatInstruction = buildOutputFormatInstruction(outputSchema); + } + const agentResult = await runAgent(options, ctx); - const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot); + const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot, ctx); const stepHash = await persistStep({ ctx, outputHash, diff --git a/packages/uwf-agent-kit/src/types.ts b/packages/uwf-agent-kit/src/types.ts index 9ea2d65..e9a3cd3 100644 --- a/packages/uwf-agent-kit/src/types.ts +++ b/packages/uwf-agent-kit/src/types.ts @@ -6,6 +6,12 @@ export type AgentContext = ModeratorContext & { role: string; store: Store; workflow: WorkflowPayload; + /** + * Prepend to the role's systemPrompt when building the agent prompt. + * Contains the frontmatter deliverable format instruction derived from the + * role's output schema. Populated by `createAgent` at run time. + */ + outputFormatInstruction: string; }; export type AgentRunResult = {