fix: dynamic frontmatter instruction from role schema (closes #389)
This commit is contained in:
@@ -2,13 +2,32 @@ import { describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
import { buildOutputFormatInstruction } from "../src/build-output-format-instruction.js";
|
||||||
|
|
||||||
|
const PLANNER_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["ready", "insufficient_info"] },
|
||||||
|
plan: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["status"],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const REVIEWER_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
approved: { type: "boolean" },
|
||||||
|
},
|
||||||
|
required: ["approved"],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
describe("buildOutputFormatInstruction", () => {
|
describe("buildOutputFormatInstruction", () => {
|
||||||
test("always includes the frontmatter example block", () => {
|
test("always includes the frontmatter example block", () => {
|
||||||
const result = buildOutputFormatInstruction({});
|
const result = buildOutputFormatInstruction({});
|
||||||
expect(result).toContain("---");
|
expect(result).toContain("---");
|
||||||
expect(result).toContain("status: done");
|
expect(result).not.toContain("status: done");
|
||||||
expect(result).toContain("confidence:");
|
expect(result).not.toContain("confidence:");
|
||||||
expect(result).toContain("scope: role");
|
expect(result).not.toContain("scope: role");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("always marks frontmatter as the primary deliverable", () => {
|
test("always marks frontmatter as the primary deliverable", () => {
|
||||||
@@ -16,17 +35,36 @@ describe("buildOutputFormatInstruction", () => {
|
|||||||
expect(result).toContain("primary deliverable");
|
expect(result).toContain("primary deliverable");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists fields from a flat object schema", () => {
|
test("generates planner-specific YAML example from schema", () => {
|
||||||
|
const result = buildOutputFormatInstruction(PLANNER_SCHEMA);
|
||||||
|
expect(result).toContain("status: ready # required | ready | insufficient_info");
|
||||||
|
expect(result).toContain("plan: <string>");
|
||||||
|
expect(result).not.toContain("status: done");
|
||||||
|
expect(result).not.toContain("confidence:");
|
||||||
|
expect(result).not.toContain("artifacts:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generates reviewer-specific YAML example from schema", () => {
|
||||||
|
const result = buildOutputFormatInstruction(REVIEWER_SCHEMA);
|
||||||
|
expect(result).toContain("approved: true # required | true | false");
|
||||||
|
expect(result).not.toContain("status:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists fields from a flat object schema with required marker", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
status: { type: "string" },
|
status: { type: "string" },
|
||||||
confidence: { type: "number" },
|
confidence: { type: "number" },
|
||||||
},
|
},
|
||||||
|
required: ["status"],
|
||||||
};
|
};
|
||||||
const result = buildOutputFormatInstruction(schema);
|
const result = buildOutputFormatInstruction(schema);
|
||||||
expect(result).toContain("`status`");
|
expect(result).toContain("`status` (required)");
|
||||||
expect(result).toContain("`confidence`");
|
expect(result).toContain("`confidence`");
|
||||||
|
expect(result).not.toContain("`confidence` (required)");
|
||||||
|
expect(result).toContain("status: <string> # required");
|
||||||
|
expect(result).toContain("confidence: <number>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists union of fields from an anyOf schema", () => {
|
test("lists union of fields from an anyOf schema", () => {
|
||||||
@@ -45,6 +83,8 @@ describe("buildOutputFormatInstruction", () => {
|
|||||||
const result = buildOutputFormatInstruction(schema);
|
const result = buildOutputFormatInstruction(schema);
|
||||||
expect(result).toContain("`alpha`");
|
expect(result).toContain("`alpha`");
|
||||||
expect(result).toContain("`beta`");
|
expect(result).toContain("`beta`");
|
||||||
|
expect(result).toContain("alpha: <string>");
|
||||||
|
expect(result).toContain("beta: <number>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists union of fields from a oneOf schema", () => {
|
test("lists union of fields from a oneOf schema", () => {
|
||||||
@@ -63,6 +103,8 @@ describe("buildOutputFormatInstruction", () => {
|
|||||||
const result = buildOutputFormatInstruction(schema);
|
const result = buildOutputFormatInstruction(schema);
|
||||||
expect(result).toContain("`foo`");
|
expect(result).toContain("`foo`");
|
||||||
expect(result).toContain("`bar`");
|
expect(result).toContain("`bar`");
|
||||||
|
expect(result).toContain("foo: <string>");
|
||||||
|
expect(result).toContain("bar: true # true | false");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back gracefully for a non-object schema with no properties", () => {
|
test("falls back gracefully for a non-object schema with no properties", () => {
|
||||||
@@ -80,6 +122,23 @@ describe("buildOutputFormatInstruction", () => {
|
|||||||
const result = buildOutputFormatInstruction(schema);
|
const result = buildOutputFormatInstruction(schema);
|
||||||
const matches = [...result.matchAll(/`shared`/g)];
|
const matches = [...result.matchAll(/`shared`/g)];
|
||||||
expect(matches.length).toBe(1);
|
expect(matches.length).toBe(1);
|
||||||
|
expect(result).toContain("shared: <string>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks required when any union variant requires the field", () => {
|
||||||
|
const schema = {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { shared: { type: "string" } },
|
||||||
|
required: ["shared"],
|
||||||
|
},
|
||||||
|
{ type: "object", properties: { shared: { type: "number" } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = buildOutputFormatInstruction(schema);
|
||||||
|
expect(result).toContain("`shared` (required)");
|
||||||
|
expect(result).toContain("shared: <string> # required");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("includes focus reminder about role scope", () => {
|
test("includes focus reminder about role scope", () => {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { JSONSchema } from "@uncaged/json-cas";
|
import type { JSONSchema } from "@uncaged/json-cas";
|
||||||
|
|
||||||
|
type SchemaProperty = {
|
||||||
|
name: string;
|
||||||
|
schema: JSONSchema;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract top-level property names from a JSON Schema object.
|
* Extract top-level property names from a JSON Schema object.
|
||||||
*
|
*
|
||||||
@@ -10,8 +16,43 @@ 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.
|
||||||
*/
|
*/
|
||||||
export function extractSchemaFields(schema: JSONSchema): string[] {
|
export function extractSchemaFields(schema: JSONSchema): string[] {
|
||||||
|
return extractSchemaProperties(schema).map((p) => p.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSchemaProperties(schema: JSONSchema): SchemaProperty[] {
|
||||||
|
const objectSchemas = collectObjectSchemas(schema);
|
||||||
|
if (objectSchemas.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = new Map<string, SchemaProperty>();
|
||||||
|
|
||||||
|
for (const objectSchema of objectSchemas) {
|
||||||
|
const requiredSet = new Set(
|
||||||
|
Array.isArray(objectSchema.required) ? (objectSchema.required as string[]) : [],
|
||||||
|
);
|
||||||
|
const properties = objectSchema.properties as Record<string, JSONSchema> | null | undefined;
|
||||||
|
if (typeof properties !== "object" || properties === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, propSchema] of Object.entries(properties)) {
|
||||||
|
const required = requiredSet.has(name);
|
||||||
|
const existing = byName.get(name);
|
||||||
|
if (existing === undefined) {
|
||||||
|
byName.set(name, { name, schema: propSchema, required });
|
||||||
|
} else if (required) {
|
||||||
|
byName.set(name, { ...existing, required: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byName.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectObjectSchemas(schema: JSONSchema): JSONSchema[] {
|
||||||
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 [schema];
|
||||||
}
|
}
|
||||||
|
|
||||||
const unionKey = Array.isArray(schema.anyOf)
|
const unionKey = Array.isArray(schema.anyOf)
|
||||||
@@ -20,18 +61,109 @@ export function extractSchemaFields(schema: JSONSchema): string[] {
|
|||||||
? "oneOf"
|
? "oneOf"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (unionKey !== null) {
|
if (unionKey === null) {
|
||||||
const variants = schema[unionKey] as JSONSchema[];
|
return [];
|
||||||
const fieldSet = new Set<string>();
|
|
||||||
for (const variant of variants) {
|
|
||||||
for (const field of extractSchemaFields(variant)) {
|
|
||||||
fieldSet.add(field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...fieldSet];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
const variants = schema[unionKey] as JSONSchema[];
|
||||||
|
const result: JSONSchema[] = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
result.push(...collectObjectSchemas(variant));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePropertySchema(prop: JSONSchema): JSONSchema {
|
||||||
|
if (Array.isArray(prop.enum) && prop.enum.length > 0) {
|
||||||
|
return prop;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unionKey = Array.isArray(prop.anyOf) ? "anyOf" : Array.isArray(prop.oneOf) ? "oneOf" : null;
|
||||||
|
|
||||||
|
if (unionKey !== null) {
|
||||||
|
const variants = prop[unionKey] as JSONSchema[];
|
||||||
|
const nonNull = variants.filter((v) => v.type !== "null");
|
||||||
|
if (nonNull.length === 1) {
|
||||||
|
return nonNull[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYamlScalar(value: unknown): string {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPropertyComment(parts: string[]): string {
|
||||||
|
const filtered = parts.filter((p) => p.length > 0);
|
||||||
|
return filtered.length > 0 ? ` # ${filtered.join(" | ")}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPropertyExampleLine(prop: SchemaProperty): string {
|
||||||
|
const resolved = resolvePropertySchema(prop.schema);
|
||||||
|
const commentParts: string[] = [];
|
||||||
|
if (prop.required) {
|
||||||
|
commentParts.push("required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) {
|
||||||
|
const enumValues = resolved.enum.map((v) => String(v));
|
||||||
|
commentParts.push(...enumValues);
|
||||||
|
const first = resolved.enum[0];
|
||||||
|
return `${prop.name}: ${formatYamlScalar(first)}${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.type === "boolean") {
|
||||||
|
commentParts.push("true", "false");
|
||||||
|
return `${prop.name}: true${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.type === "string") {
|
||||||
|
return `${prop.name}: <string>${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.type === "number" || resolved.type === "integer") {
|
||||||
|
return `${prop.name}: <number>${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.type === "array") {
|
||||||
|
return `${prop.name}:\n - <item>${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolved.type === "object") {
|
||||||
|
return `${prop.name}: <object>${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prop.name}: <value>${buildPropertyComment(commentParts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildYamlExampleBlock(properties: SchemaProperty[]): string {
|
||||||
|
if (properties.length === 0) {
|
||||||
|
return "---\n\n... your markdown work here ...";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = properties.map((p) => buildPropertyExampleLine(p));
|
||||||
|
return `---\n${lines.join("\n")}\n---\n\n... your markdown work here ...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldList(properties: SchemaProperty[]): string {
|
||||||
|
if (properties.length === 0) {
|
||||||
|
return " (schema fields will be extracted automatically)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties
|
||||||
|
.map((p) => {
|
||||||
|
const suffix = p.required ? " (required)" : "";
|
||||||
|
return ` - \`${p.name}\`${suffix}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,28 +174,16 @@ export function extractSchemaFields(schema: JSONSchema): string[] {
|
|||||||
* system prompt so the deliverable format is the first thing the agent sees.
|
* system prompt so the deliverable format is the first thing the agent sees.
|
||||||
*/
|
*/
|
||||||
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
export function buildOutputFormatInstruction(schema: JSONSchema): string {
|
||||||
const fields = extractSchemaFields(schema);
|
const properties = extractSchemaProperties(schema);
|
||||||
|
const yamlExample = buildYamlExampleBlock(properties);
|
||||||
const fieldList =
|
const fieldList = buildFieldList(properties);
|
||||||
fields.length > 0
|
|
||||||
? fields.map((f) => ` - \`${f}\``).join("\n")
|
|
||||||
: " (schema fields will be extracted automatically)";
|
|
||||||
|
|
||||||
return `## Deliverable Format
|
return `## Deliverable Format
|
||||||
|
|
||||||
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
Your response MUST begin with a YAML frontmatter block followed by your markdown work:
|
||||||
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
---
|
${yamlExample}
|
||||||
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.
|
The frontmatter is the **primary deliverable** — the engine reads it directly.
|
||||||
|
|||||||
Reference in New Issue
Block a user