From 1b4fd9cbae0331b07b8cbf9b2ded14eaf6ff42e3 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 27 Feb 2026 12:17:03 +0100 Subject: [PATCH] feat(tools): added normalizeMixedSchemaNode() to handle JTD and JSON Schema - Added normalizeMixedSchemaNode() function to recursively convert mixed JTD and JSON Schema definitions into valid JSON Schema. - Changed output schema validation to gracefully fall back to unconstrained JSON objects when schema is invalid instead of throwing errors. - Fixed handling of mixed JTD and JSON Schema output definitions by normalizing them during schema conversion. - Added comprehensive test coverage for JTD-to-JSON Schema conversion and output schema validation edge cases. --- packages/coding-agent/CHANGELOG.md | 4 +- .../src/tools/jtd-to-json-schema.ts | 42 +++++++---- .../coding-agent/src/tools/submit-result.ts | 21 +++--- .../test/tools/jtd-to-json-schema.test.ts | 72 +++++++++++++++++++ .../test/tools/submit-result.test.ts | 63 ++++++++++++++++ 5 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 packages/coding-agent/test/tools/jtd-to-json-schema.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9b8c90829..abc80f3e3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,7 +1,6 @@ # Changelog ## [Unreleased] - ### Breaking Changes - Removed `preloadedSkills` option from `CreateAgentSessionOptions`; skills are no longer inlined into system prompts @@ -11,6 +10,8 @@ ### Changed +- Changed JTD-to-JSON Schema conversion to normalize nested JTD fragments within JSON Schema nodes, enabling mixed schema definitions +- Changed output schema validation to gracefully fall back to unconstrained object when schema is invalid, instead of rejecting submissions - Changed schema sanitization to remove strict-mode incompatible constraints (minLength, pattern, etc.) from tool parameters while preserving them for runtime validation - Simplified task execution to always pass available session skills to subagents instead of resolving per-task skill lists @@ -20,6 +21,7 @@ ### Fixed +- Fixed handling of mixed JTD and JSON Schema output definitions to properly convert all nested JTD elements (e.g., `elements` → `items`, `int32` → `integer`) - Fixed strict schema generation for output schemas with only required fields, enabling proper Claude API compatibility - Fixed handling of union type schemas (e.g., object|null) to normalize them into strict-mode compatible variants diff --git a/packages/coding-agent/src/tools/jtd-to-json-schema.ts b/packages/coding-agent/src/tools/jtd-to-json-schema.ts index 061c26c82..8e76bf6d2 100644 --- a/packages/coding-agent/src/tools/jtd-to-json-schema.ts +++ b/packages/coding-agent/src/tools/jtd-to-json-schema.ts @@ -38,15 +38,6 @@ function convertSchema(schema: unknown): unknown { return {}; } - // Type form: { type: "string" } → { type: "string" } - if (isJTDType(schema)) { - const jsonType = primitiveMap[schema.type as JTDPrimitive]; - if (!jsonType) { - return { type: schema.type }; - } - return { type: jsonType }; - } - // Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] } if (isJTDEnum(schema)) { return { enum: schema.enum }; @@ -60,6 +51,14 @@ function convertSchema(schema: unknown): unknown { }; } + // Type form: { type: "string" } → { type: "string" } + if (isJTDType(schema)) { + const jsonType = primitiveMap[schema.type as JTDPrimitive]; + if (!jsonType) { + return { type: schema.type }; + } + return { type: jsonType }; + } // Values form: { values: { type: "string" } } → { type: "object", additionalProperties: ... } if (isJTDValues(schema)) { return { @@ -171,13 +170,30 @@ export function isJTDSchema(schema: unknown): boolean { return false; } +function normalizeMixedSchemaNode(schema: unknown): unknown { + if (schema === null || typeof schema !== "object") { + return schema; + } + + if (Array.isArray(schema)) { + return schema.map(item => normalizeMixedSchemaNode(item)); + } + + if (isJTDSchema(schema)) { + return normalizeMixedSchemaNode(convertSchema(schema)); + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(schema)) { + normalized[key] = normalizeMixedSchemaNode(value); + } + + return normalized; +} /** * Convert JTD schema to JSON Schema. * If already JSON Schema, returns as-is. */ export function jtdToJsonSchema(schema: unknown): unknown { - if (!isJTDSchema(schema)) { - return schema; - } - return convertSchema(schema); + return normalizeMixedSchemaNode(schema); } diff --git a/packages/coding-agent/src/tools/submit-result.ts b/packages/coding-agent/src/tools/submit-result.ts index 6f7c52772..f24b1b26c 100644 --- a/packages/coding-agent/src/tools/submit-result.ts +++ b/packages/coding-agent/src/tools/submit-result.ts @@ -62,7 +62,6 @@ export class SubmitResultTool implements AgentTool strict = true; readonly #validate?: ValidateFunction; - readonly #schemaError?: string; constructor(session: ToolSession) { const schemaResult = normalizeSchema(session.outputSchema); @@ -79,15 +78,18 @@ export class SubmitResultTool implements AgentTool } } - this.#schemaError = schemaError; - const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema); - const schemaDescription = `Structured output matching the schema:\n${schemaHint}`; + const schemaDescription = schemaError + ? `Structured JSON output (output schema invalid; accepting unconstrained object): ${schemaError}` + : `Structured output matching the schema:\n${schemaHint}`; const sanitizedSchema = - normalizedSchema != null && typeof normalizedSchema === "object" && !Array.isArray(normalizedSchema) + !schemaError && + normalizedSchema != null && + typeof normalizedSchema === "object" && + !Array.isArray(normalizedSchema) ? sanitizeSchemaForStrictMode(normalizedSchema as Record) - : normalizedSchema === true + : !schemaError && normalizedSchema === true ? {} : undefined; @@ -97,7 +99,9 @@ export class SubmitResultTool implements AgentTool ...sanitizedSchema, description: schemaDescription, }) - : Type.Record(Type.String(), Type.Any(), { description: "Structured JSON output (no schema specified)" }); + : Type.Record(Type.String(), Type.Any(), { + description: schemaError ? schemaDescription : "Structured JSON output (no schema specified)", + }); this.parameters = Type.Object( { result: Type.Union([ @@ -148,9 +152,6 @@ export class SubmitResultTool implements AgentTool if (data === undefined || data === null) { throw new Error("data is required when submit_result indicates success"); } - if (this.#schemaError) { - throw new Error(`Invalid output schema: ${this.#schemaError}`); - } if (this.#validate && !this.#validate(data)) { throw new Error(`Output does not match schema: ${formatAjvErrors(this.#validate.errors)}`); } diff --git a/packages/coding-agent/test/tools/jtd-to-json-schema.test.ts b/packages/coding-agent/test/tools/jtd-to-json-schema.test.ts new file mode 100644 index 000000000..8b7fd5fef --- /dev/null +++ b/packages/coding-agent/test/tools/jtd-to-json-schema.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test"; +import { jtdToJsonSchema } from "@oh-my-pi/pi-coding-agent/tools/jtd-to-json-schema"; + +describe("jtdToJsonSchema", () => { + it("converts JTD elements and int32 primitives into JSON Schema", () => { + const converted = jtdToJsonSchema({ + properties: { + results: { + elements: { + properties: { + issue: { type: "int32" }, + }, + }, + }, + }, + }); + + expect(converted).toEqual({ + type: "object", + properties: { + results: { + type: "array", + items: { + type: "object", + properties: { + issue: { type: "integer" }, + }, + required: ["issue"], + additionalProperties: false, + }, + }, + }, + required: ["results"], + additionalProperties: false, + }); + }); + + it("normalizes nested JTD fragments inside JSON Schema nodes", () => { + const converted = jtdToJsonSchema({ + type: "object", + properties: { + results: { + type: "array", + elements: { + properties: { + issue: { type: "int32" }, + }, + }, + }, + }, + required: ["results"], + }); + + expect(converted).toEqual({ + type: "object", + properties: { + results: { + type: "array", + items: { + type: "object", + properties: { + issue: { type: "integer" }, + }, + required: ["issue"], + additionalProperties: false, + }, + }, + }, + required: ["results"], + }); + }); +}); diff --git a/packages/coding-agent/test/tools/submit-result.test.ts b/packages/coding-agent/test/tools/submit-result.test.ts index 2774c182f..9d132fcac 100644 --- a/packages/coding-agent/test/tools/submit-result.test.ts +++ b/packages/coding-agent/test/tools/submit-result.test.ts @@ -126,6 +126,69 @@ describe("SubmitResultTool", () => { expect(nullVariant).toEqual({ type: "null" }); }); + it("converts mixed JTD and JSON Schema output definitions into provider-valid schemas", async () => { + const tool = new SubmitResultTool( + createSession({ + outputSchema: { + type: "object", + properties: { + results: { + type: "array", + elements: { + properties: { + issue: { type: "int32" }, + }, + }, + }, + }, + required: ["results"], + }, + }), + ); + const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record); + const resultsSchema = toRecord(toRecord(dataSchema.properties).results); + const issueSchema = toRecord(toRecord(toRecord(resultsSchema.items).properties).issue); + + expect(resultsSchema.type).toBe("array"); + expect(resultsSchema.items).toBeDefined(); + expect(resultsSchema.elements).toBeUndefined(); + expect(issueSchema.type).toBe("integer"); + + await expect( + tool.execute("call-mixed-valid", { result: { data: { results: [{ issue: 185 }] } } } as never), + ).resolves.toBeDefined(); + await expect( + tool.execute("call-mixed-invalid", { result: { data: { results: [{ issue: "185" }] } } } as never), + ).rejects.toThrow("Output does not match schema"); + }); + it("falls back to unconstrained object data when output schema is invalid", async () => { + const tool = new SubmitResultTool( + createSession({ + outputSchema: { + type: "object", + properties: { + value: { type: "not-a-real-json-schema-type" }, + }, + required: ["value"], + }, + }), + ); + const dataSchema = getSuccessDataSchema(tool.parameters as unknown as Record); + const dataSchemaProperties = toRecord(dataSchema.properties); + + expect(dataSchema.type).toBe("object"); + expect(dataSchemaProperties.value).toBeUndefined(); + expect(Object.keys(dataSchemaProperties)).toHaveLength(0); + + const result = await tool.execute("call-invalid-schema", { + result: { data: { value: 123, nested: { ok: true } } }, + } as never); + expect(result.details).toEqual({ + data: { value: 123, nested: { ok: true } }, + status: "success", + error: undefined, + }); + }); it("keeps runtime validation against the original output schema", async () => { const outputSchema = { type: "object",