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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user