fix(ai): scoped union branch tagging to combinator path

Only mark Zod and JSON Schema validation issues as union-branch when their own path matches the combinator's path so nested array fields inside a tag-selected branch keep their singleton-wrap repair.

Fixes #2026
This commit is contained in:
roboomp
2026-06-07 06:50:06 +00:00
parent 9cd1883941
commit 64e71e16fe
3 changed files with 69 additions and 2 deletions
@@ -250,8 +250,16 @@ function validateSchemaNode(
const branchValid = keyword === "anyOf" ? matches > 0 : matches === 1;
if (!branchValid) {
if (matches === 0 && firstIssues && firstIssues.length > 0) {
// Only tag issues that sit at the combinator's own path as
// union-branch; deeper issues describe a specific field within
// the failed branch and should remain individually repairable.
const unionDepth = path.length;
for (const branchIssue of firstIssues) {
issues.push({ ...branchIssue, fromUnionBranch: true });
if (branchIssue.path.length === unionDepth) {
issues.push({ ...branchIssue, fromUnionBranch: true });
} else {
issues.push(branchIssue);
}
}
} else {
pushIssue(
+5 -1
View File
@@ -784,9 +784,13 @@ function flattenIssues(issues: ReadonlyArray<ZodIssue>): FlatIssue[] {
if (issue.code === "invalid_union") {
const inner = (issue as unknown as { errors?: ReadonlyArray<ReadonlyArray<ZodIssue>> }).errors;
if (inner) {
// A union-branch issue only competes with a sibling branch when it
// sits at the union node's own path. Issues whose own path is
// non-empty live on a deeper field that an already-identified
// branch owns, so the singleton-array repair should still apply.
for (const branch of inner) {
for (const child of branch) {
walk(child, fullPath, true);
walk(child, fullPath, child.path.length === 0);
}
}
}
@@ -195,6 +195,61 @@ describe("Tool argument coercion", () => {
).toThrow("Validation failed");
});
it("still wraps nested array fields inside a tag-selected Zod union branch", () => {
const tool: Tool = {
name: "tagged_union",
description: "",
parameters: z.union([
z.object({ type: z.literal("indices"), indices: z.array(z.number()) }),
z.object({ type: z.literal("all") }),
]),
};
const result = validateToolArguments(tool, {
type: "toolCall",
id: "call-tagged-union",
name: "tagged_union",
arguments: { type: "indices", indices: 1 },
});
expect(result).toEqual({ type: "indices", indices: [1] });
});
it("still wraps nested array fields inside a tag-selected JSON Schema anyOf branch", () => {
const tool: Tool = {
name: "tagged_json_union",
description: "",
parameters: {
anyOf: [
{
type: "object",
properties: {
type: { const: "indices" },
indices: { type: "array", items: { type: "number" } },
},
required: ["type", "indices"],
additionalProperties: false,
},
{
type: "object",
properties: { type: { const: "all" } },
required: ["type"],
additionalProperties: false,
},
],
},
};
const result = validateToolArguments(tool, {
type: "toolCall",
id: "call-tagged-json-union",
name: "tagged_json_union",
arguments: { type: "indices", indices: 1 },
});
expect(result).toEqual({ type: "indices", indices: [1] });
});
it("parses JSON objects in string values when schema expects object", () => {
const tool: Tool = {
name: "t4",