fix: dynamic frontmatter field extraction from role schema
Replace hardcoded 5-field candidate with schema-driven extraction. Now reads outputSchema properties and picks matching fields from parsed frontmatter, supporting role-specific fields like plan, approved, success. Falls back to standard 5 fields when schema has no properties. Fixes #388 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -29,6 +29,27 @@ const STRICT_SCHEMA = {
|
|||||||
additionalProperties: false,
|
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<string, unknown>) {
|
async function makeStoreWithSchema(schema: Record<string, unknown>) {
|
||||||
const store = createMemoryStore();
|
const store = createMemoryStore();
|
||||||
const schemaHash = await putSchema(store, schema);
|
const schemaHash = await putSchema(store, schema);
|
||||||
@@ -134,3 +155,48 @@ describe("tryFrontmatterFastPath — fallback: schema mismatch", () => {
|
|||||||
expect(result).toBeNull();
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { JSONSchema } from "@uncaged/json-cas";
|
|||||||
*
|
*
|
||||||
* Returns an empty array for schemas with no inspectable property definitions.
|
* 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) {
|
if (typeof schema.properties === "object" && schema.properties !== null) {
|
||||||
return Object.keys(schema.properties as Record<string, unknown>);
|
return Object.keys(schema.properties as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,139 @@
|
|||||||
import type { Store } from "@uncaged/json-cas";
|
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 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 = {
|
export type FrontmatterFastPathResult = {
|
||||||
body: string;
|
body: string;
|
||||||
outputHash: CasRef;
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCandidate(frontmatter: AgentFrontmatter): Record<string, unknown> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
schemaFields: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (schemaFields.length === 0) {
|
||||||
|
return defaultCandidate(frontmatter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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.
|
* Try to satisfy `outputSchema` from frontmatter fields alone.
|
||||||
*
|
*
|
||||||
@@ -32,16 +158,22 @@ export async function tryFrontmatterFastPath(
|
|||||||
|
|
||||||
const validationErrors = validateFrontmatter(frontmatter);
|
const validationErrors = validateFrontmatter(frontmatter);
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
|
log(
|
||||||
|
"9GNPS4WY",
|
||||||
|
`frontmatter validation errors: ${validationErrors.map((e) => e.message).join("; ")}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate: Record<string, unknown> = {
|
const schema = getSchema(store, outputSchema);
|
||||||
status: frontmatter.status,
|
if (schema === null) {
|
||||||
next: frontmatter.next,
|
log("8FHMR2QX", `output schema not found in CAS: ${outputSchema}`);
|
||||||
confidence: frontmatter.confidence,
|
return null;
|
||||||
artifacts: [...frontmatter.artifacts],
|
}
|
||||||
scope: frontmatter.scope,
|
|
||||||
};
|
const schemaFields = extractSchemaFields(schema);
|
||||||
|
const rawFields = parseRawFrontmatterFields(raw);
|
||||||
|
const candidate = buildCandidate(frontmatter, rawFields, schemaFields);
|
||||||
|
|
||||||
let outputHash: CasRef;
|
let outputHash: CasRef;
|
||||||
let node: ReturnType<Store["get"]>;
|
let node: ReturnType<Store["get"]>;
|
||||||
@@ -50,10 +182,12 @@ export async function tryFrontmatterFastPath(
|
|||||||
outputHash = await store.put(outputSchema, candidate);
|
outputHash = await store.put(outputSchema, candidate);
|
||||||
node = store.get(outputHash);
|
node = store.get(outputHash);
|
||||||
} catch {
|
} catch {
|
||||||
|
log("2KMQT7NR", "failed to store frontmatter candidate in CAS");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node === null || !validate(store, node)) {
|
if (node === null || !validate(store, node)) {
|
||||||
|
log("2KMQT7NR", "stored frontmatter candidate failed schema validation");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user