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.
This commit is contained in:
can1357
2026-02-27 12:17:03 +01:00
parent 5af63f755d
commit 1b4fd9cbae
5 changed files with 178 additions and 24 deletions
+3 -1
View File
@@ -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
@@ -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<string, unknown> = {};
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);
}
@@ -62,7 +62,6 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
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<TSchema, SubmitResultDetails>
}
}
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<string, unknown>)
: normalizedSchema === true
: !schemaError && normalizedSchema === true
? {}
: undefined;
@@ -97,7 +99,9 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
...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<TSchema, SubmitResultDetails>
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)}`);
}
@@ -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"],
});
});
});
@@ -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<string, unknown>);
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<string, unknown>);
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",