From 4d47effd39e206f56f8ceba7cd93f6904beed706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 22 May 2026 10:03:31 +0000 Subject: [PATCH] fix: generate frontmatter instruction dynamically from role schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded 5-field example with schema-driven generation. Now shows actual enum values, types, and required markers for each role's frontmatter schema. Fixes #389 小橘 --- .../build-output-format-instruction.test.ts | 69 ++++++- .../src/build-output-format-instruction.ts | 174 +++++++++++++++--- 2 files changed, 211 insertions(+), 32 deletions(-) diff --git a/packages/workflow-agent-kit/__tests__/build-output-format-instruction.test.ts b/packages/workflow-agent-kit/__tests__/build-output-format-instruction.test.ts index 31e4680..fad5030 100644 --- a/packages/workflow-agent-kit/__tests__/build-output-format-instruction.test.ts +++ b/packages/workflow-agent-kit/__tests__/build-output-format-instruction.test.ts @@ -2,13 +2,32 @@ import { describe, expect, test } from "vitest"; 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", () => { 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"); + expect(result).not.toContain("status: done"); + expect(result).not.toContain("confidence:"); + expect(result).not.toContain("scope: role"); }); test("always marks frontmatter as the primary deliverable", () => { @@ -16,17 +35,36 @@ describe("buildOutputFormatInstruction", () => { 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: "); + 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 = { type: "object", properties: { status: { type: "string" }, confidence: { type: "number" }, }, + required: ["status"], }; const result = buildOutputFormatInstruction(schema); - expect(result).toContain("`status`"); + expect(result).toContain("`status` (required)"); expect(result).toContain("`confidence`"); + expect(result).not.toContain("`confidence` (required)"); + expect(result).toContain("status: # required"); + expect(result).toContain("confidence: "); }); test("lists union of fields from an anyOf schema", () => { @@ -45,6 +83,8 @@ describe("buildOutputFormatInstruction", () => { const result = buildOutputFormatInstruction(schema); expect(result).toContain("`alpha`"); expect(result).toContain("`beta`"); + expect(result).toContain("alpha: "); + expect(result).toContain("beta: "); }); test("lists union of fields from a oneOf schema", () => { @@ -63,6 +103,8 @@ describe("buildOutputFormatInstruction", () => { const result = buildOutputFormatInstruction(schema); expect(result).toContain("`foo`"); expect(result).toContain("`bar`"); + expect(result).toContain("foo: "); + expect(result).toContain("bar: true # true | false"); }); test("falls back gracefully for a non-object schema with no properties", () => { @@ -80,6 +122,23 @@ describe("buildOutputFormatInstruction", () => { const result = buildOutputFormatInstruction(schema); const matches = [...result.matchAll(/`shared`/g)]; expect(matches.length).toBe(1); + expect(result).toContain("shared: "); + }); + + 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: # required"); }); test("includes focus reminder about role scope", () => { 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 2f8e7cd..52f4356 100644 --- a/packages/workflow-agent-kit/src/build-output-format-instruction.ts +++ b/packages/workflow-agent-kit/src/build-output-format-instruction.ts @@ -1,5 +1,11 @@ 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. * @@ -10,8 +16,43 @@ import type { JSONSchema } from "@uncaged/json-cas"; * Returns an empty array for schemas with no inspectable property definitions. */ 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(); + + for (const objectSchema of objectSchemas) { + const requiredSet = new Set( + Array.isArray(objectSchema.required) ? (objectSchema.required as string[]) : [], + ); + const properties = objectSchema.properties as Record | 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) { - return Object.keys(schema.properties as Record); + return [schema]; } const unionKey = Array.isArray(schema.anyOf) @@ -20,18 +61,109 @@ export function extractSchemaFields(schema: JSONSchema): string[] { ? "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]; + if (unionKey === null) { + return []; } - 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}: ${buildPropertyComment(commentParts)}`; + } + + if (resolved.type === "number" || resolved.type === "integer") { + return `${prop.name}: ${buildPropertyComment(commentParts)}`; + } + + if (resolved.type === "array") { + return `${prop.name}:\n - ${buildPropertyComment(commentParts)}`; + } + + if (resolved.type === "object") { + return `${prop.name}: ${buildPropertyComment(commentParts)}`; + } + + return `${prop.name}: ${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. */ 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)"; + const properties = extractSchemaProperties(schema); + const yamlExample = buildYamlExampleBlock(properties); + const fieldList = buildFieldList(properties); 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 ... +${yamlExample} \`\`\` The frontmatter is the **primary deliverable** — the engine reads it directly.