diff --git a/packages/workflow-agent-kit/__tests__/frontmatter-fast-path.test.ts b/packages/workflow-agent-kit/__tests__/frontmatter-fast-path.test.ts index c41920b..69df302 100644 --- a/packages/workflow-agent-kit/__tests__/frontmatter-fast-path.test.ts +++ b/packages/workflow-agent-kit/__tests__/frontmatter-fast-path.test.ts @@ -29,6 +29,27 @@ const STRICT_SCHEMA = { additionalProperties: false, }; +/** Role-specific schema (reviewer) — only approved, no standard agent fields. */ +const REVIEWER_SCHEMA = { + type: "object", + properties: { + approved: { type: "boolean" }, + }, + required: ["approved"], + additionalProperties: false, +}; + +/** Role-specific schema (planner) — custom status enum + plan hash. */ +const PLANNER_SCHEMA = { + type: "object", + properties: { + status: { type: "string", enum: ["ready", "insufficient_info"] }, + plan: { type: "string" }, + }, + required: ["status"], + additionalProperties: false, +}; + async function makeStoreWithSchema(schema: Record) { const store = createMemoryStore(); const schemaHash = await putSchema(store, schema); @@ -134,3 +155,48 @@ describe("tryFrontmatterFastPath — fallback: schema mismatch", () => { expect(result).toBeNull(); }); }); + +// ── Role-specific schema fields ─────────────────────────────────────────────── + +describe("tryFrontmatterFastPath — role-specific fields", () => { + test("extracts approved only for reviewer schema (no extra standard fields)", async () => { + const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA); + + const raw = "---\napproved: true\n---\n\nReview passed."; + + 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).toEqual({ approved: true }); + expect(payload.status).toBeUndefined(); + expect(payload.scope).toBeUndefined(); + }); + + test("extracts plan and role-specific status for planner schema", async () => { + const { store, schemaHash } = await makeStoreWithSchema(PLANNER_SCHEMA); + + const raw = "---\nstatus: ready\nplan: 01HASHPLANNER0001\n---\n\nSpec summary."; + + 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("ready"); + expect(payload.plan).toBe("01HASHPLANNER0001"); + expect(payload.scope).toBeUndefined(); + }); + + test("returns null when required role-specific field is missing", async () => { + const { store, schemaHash } = await makeStoreWithSchema(REVIEWER_SCHEMA); + + const raw = "---\nstatus: done\nscope: role\n---\n\nBody."; + + const result = await tryFrontmatterFastPath(raw, schemaHash, store); + expect(result).toBeNull(); + }); +}); diff --git a/packages/workflow-agent-kit/src/build-output-format-instruction.ts b/packages/workflow-agent-kit/src/build-output-format-instruction.ts index 5ecb381..2f8e7cd 100644 --- a/packages/workflow-agent-kit/src/build-output-format-instruction.ts +++ b/packages/workflow-agent-kit/src/build-output-format-instruction.ts @@ -9,7 +9,7 @@ import type { JSONSchema } from "@uncaged/json-cas"; * * Returns an empty array for schemas with no inspectable property definitions. */ -function extractSchemaFields(schema: JSONSchema): string[] { +export function extractSchemaFields(schema: JSONSchema): string[] { if (typeof schema.properties === "object" && schema.properties !== null) { return Object.keys(schema.properties as Record); } diff --git a/packages/workflow-agent-kit/src/frontmatter.ts b/packages/workflow-agent-kit/src/frontmatter.ts index 49075fe..3e49666 100644 --- a/packages/workflow-agent-kit/src/frontmatter.ts +++ b/packages/workflow-agent-kit/src/frontmatter.ts @@ -1,13 +1,139 @@ import type { Store } from "@uncaged/json-cas"; -import { validate } from "@uncaged/json-cas"; +import { getSchema, validate } from "@uncaged/json-cas"; import type { CasRef } from "@uncaged/workflow-protocol"; -import { parseFrontmatterMarkdown, validateFrontmatter } from "@uncaged/workflow-util"; +import { + type AgentFrontmatter, + createLogger, + parseFrontmatterMarkdown, + validateFrontmatter, +} from "@uncaged/workflow-util"; +import { parse as parseYaml } from "yaml"; + +import { extractSchemaFields } from "./build-output-format-instruction.js"; + +const log = createLogger({ sink: { kind: "stderr" } }); + +const STANDARD_KEYS = ["status", "next", "confidence", "artifacts", "scope"] as const; + +type StandardKey = (typeof STANDARD_KEYS)[number]; export type FrontmatterFastPathResult = { body: string; outputHash: CasRef; }; +function extractYamlBlock(raw: string): string | null { + const fence = "---"; + if (!raw.startsWith(fence)) { + return null; + } + + const rest = raw.slice(fence.length); + if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") { + return null; + } + + const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest; + const closeIndex = afterOpen.indexOf(`\n${fence}`); + if (closeIndex === -1) { + return null; + } + + return afterOpen.slice(0, closeIndex); +} + +function parseRawFrontmatterFields(raw: string): Record { + const yamlText = extractYamlBlock(raw); + if (yamlText === null) { + return {}; + } + + try { + const parsed = parseYaml(yamlText); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {}; + } + return parsed as Record; + } catch { + return {}; + } +} + +function defaultCandidate(frontmatter: AgentFrontmatter): Record { + return { + status: frontmatter.status, + next: frontmatter.next, + confidence: frontmatter.confidence, + artifacts: [...frontmatter.artifacts], + scope: frontmatter.scope, + }; +} + +function pickStandardField(frontmatter: AgentFrontmatter, key: StandardKey): unknown { + switch (key) { + case "status": + return frontmatter.status; + case "next": + return frontmatter.next; + case "confidence": + return frontmatter.confidence; + case "artifacts": + return [...frontmatter.artifacts]; + case "scope": + return frontmatter.scope; + } +} + +function isStandardKey(key: string): key is StandardKey { + return (STANDARD_KEYS as readonly string[]).includes(key); +} + +function pickFieldValue( + field: string, + frontmatter: AgentFrontmatter, + rawFields: Record, +): unknown | undefined { + if (!isStandardKey(field)) { + return Object.hasOwn(rawFields, field) ? rawFields[field] : undefined; + } + + const coerced = pickStandardField(frontmatter, field); + if (field === "artifacts" || field === "scope") { + return coerced; + } + if (coerced !== null) { + return coerced; + } + return Object.hasOwn(rawFields, field) ? rawFields[field] : coerced; +} + +/** + * Build a CAS candidate object from schema property keys and parsed frontmatter. + * + * When the schema has no inspectable properties, falls back to the five standard + * agent frontmatter fields for backward compatibility. + */ +function buildCandidate( + frontmatter: AgentFrontmatter, + rawFields: Record, + schemaFields: string[], +): Record { + if (schemaFields.length === 0) { + return defaultCandidate(frontmatter); + } + + const candidate: Record = {}; + + for (const field of schemaFields) { + const value = pickFieldValue(field, frontmatter, rawFields); + if (value !== undefined) { + candidate[field] = value; + } + } + + return candidate; +} + /** * Try to satisfy `outputSchema` from frontmatter fields alone. * @@ -32,16 +158,22 @@ export async function tryFrontmatterFastPath( const validationErrors = validateFrontmatter(frontmatter); if (validationErrors.length > 0) { + log( + "9GNPS4WY", + `frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`, + ); return null; } - const candidate: Record = { - status: frontmatter.status, - next: frontmatter.next, - confidence: frontmatter.confidence, - artifacts: [...frontmatter.artifacts], - scope: frontmatter.scope, - }; + const schema = getSchema(store, outputSchema); + if (schema === null) { + log("8FHMR2QX", `output schema not found in CAS: ${outputSchema}`); + return null; + } + + const schemaFields = extractSchemaFields(schema); + const rawFields = parseRawFrontmatterFields(raw); + const candidate = buildCandidate(frontmatter, rawFields, schemaFields); let outputHash: CasRef; let node: ReturnType; @@ -50,10 +182,12 @@ export async function tryFrontmatterFastPath( outputHash = await store.put(outputSchema, candidate); node = store.get(outputHash); } catch { + log("2KMQT7NR", "failed to store frontmatter candidate in CAS"); return null; } if (node === null || !validate(store, node)) { + log("2KMQT7NR", "stored frontmatter candidate failed schema validation"); return null; }