From 329e0b5f13b6dd60a64620aa657ee51a1ea46dfc Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 13:27:44 -0400 Subject: [PATCH 1/2] fix: repair anyOf tool schemas rejected by the Kimi wire Kimi rejects a tool whose parameters declare `anyOf` next to `type`, and also rejects a validation keyword appearing on both a node and its `anyOf` branches. TaskStop hits both: it adds a hand-written root `anyOf` to an object schema, so every request carrying it failed with a 400 before the model ever ran. Repair the shape where all tools converge instead of at the one call site, so MCP- and plugin-contributed schemas are covered too: - Nested nodes distribute their own constraints into each branch, which accepts exactly the same instances as before. - The root instead drops its `anyOf`. A tool's parameters must be an object, and the wire demands `type: "object"` there, so the branch form is unsatisfiable at that position; branch properties are merged up first so a tool cannot lose its arguments. Verified by replaying every builtin and connected MCP tool schema through the provider's own validator. --- .changeset/kimi-anyof-tool-schema.md | 5 + .../kosong/src/providers/pythinker-schema.ts | 110 +++++++++++- .../test/providers/pythinker-schema.test.ts | 166 ++++++++++++++---- 3 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 .changeset/kimi-anyof-tool-schema.md diff --git a/.changeset/kimi-anyof-tool-schema.md b/.changeset/kimi-anyof-tool-schema.md new file mode 100644 index 00000000..30139c19 --- /dev/null +++ b/.changeset/kimi-anyof-tool-schema.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Fix Kimi and Moonshot models rejecting every request with an invalid tool schema error when a tool declares `anyOf` alongside its own type or properties. diff --git a/packages/kosong/src/providers/pythinker-schema.ts b/packages/kosong/src/providers/pythinker-schema.ts index 1a20e3d3..67febf9b 100644 --- a/packages/kosong/src/providers/pythinker-schema.ts +++ b/packages/kosong/src/providers/pythinker-schema.ts @@ -117,7 +117,8 @@ const NUMERIC_STRUCTURE_KEYS = new Set([ * it resolves local refs, preserves combinator nodes, infers obvious * scalar/object/array types, and falls back to `string` only for nested * typeless property schemas. The root schema object is treated as a container - * and is not itself normalized. + * and is not itself type-normalized, with one exception: an `anyOf` at the root + * is folded away, because a tool's parameters must be a plain object. */ export function normalizePythinkerToolSchema(schema: Record): Record { return ensurePythinkerPropertyTypes(derefJsonSchema(schema)); @@ -128,10 +129,52 @@ function ensurePythinkerPropertyTypes(schema: Record): Record): void { + const branches = root['anyOf']; + if (!Array.isArray(branches) || !branches.every(isRecord)) { + return; + } + delete root['anyOf']; + + const rootProperties = root['properties']; + const merged: Record = isRecord(rootProperties) + ? rootProperties + : {}; + for (const branch of branches) { + const branchProperties = branch['properties']; + if (!isRecord(branchProperties)) continue; + for (const [name, property] of Object.entries(branchProperties)) { + if (!hasOwn(merged, name)) { + merged[name] = cloneJsonValue(property); + } + } + } + if (Object.keys(merged).length > 0) { + root['properties'] = merged; + } + root['type'] = 'object'; +} + function hasUnresolvedDefinitionRef(node: unknown, bucketKey: string): boolean { if (Array.isArray(node)) { return node.some((child) => hasUnresolvedDefinitionRef(child, bucketKey)); @@ -252,9 +295,74 @@ function recurseSchema(node: unknown): void { return; } + distributeAnyOfParentKeywords(node); visitChildSchemas(node, normalizeProperty); } +/** + * Keywords that may stay on a schema node that also carries `anyOf`. + * + * Everything else is a validation keyword the wire validator refuses to see on + * both sides of an `anyOf`. Sibling combinators are left alone because the + * validator does not read them at all, so relocating them would only churn the + * schema. `$defs` / `definitions` stay put because cyclic `$ref` pointers + * resolve against the root, and `$ref` stays because duplicating a cyclic + * reference into every branch changes its meaning. + */ +const ANYOF_PARENT_KEEP_KEYS = new Set([ + '$comment', + '$defs', + '$ref', + '$schema', + 'allOf', + 'anyOf', + 'default', + 'definitions', + 'description', + 'else', + 'if', + 'not', + 'oneOf', + 'then', + 'title', +]); + +/** + * Push a node's own constraints down into its `anyOf` branches. + * + * Pythoughts's tool validator rejects `anyOf` used as a refinement of its parent: + * `type` must be declared inside the branches rather than beside them, and no + * other validation keyword (`properties`, `items`, `additionalProperties`, …) + * may appear on both the parent and a branch. Standard JSON Schema allows both, + * so schemas that are perfectly valid elsewhere are rejected on this wire. + * + * Distributing is lossless: `P ∧ (B₁ ∨ B₂)` and `(P ∧ B₁) ∨ (P ∧ B₂)` accept + * exactly the same instances. A branch that already declares a keyword keeps + * its own, which is the narrower of the two. + */ +function distributeAnyOfParentKeywords(node: Record): void { + const branches = node['anyOf']; + if (!Array.isArray(branches) || branches.length === 0 || !branches.every(isRecord)) { + return; + } + + const inherited = Object.keys(node).filter((key) => !ANYOF_PARENT_KEEP_KEYS.has(key)); + if (inherited.length === 0) { + return; + } + + for (const branch of branches) { + for (const key of inherited) { + if (!hasOwn(branch, key)) { + branch[key] = cloneJsonValue(node[key]); + } + } + } + for (const key of inherited) { + delete node[key]; + } +} + function visitChildSchemas(node: Record, visit: (schema: unknown) => void): void { for (const { key, kind } of CHILD_SCHEMA_SLOTS) { const value = node[key]; diff --git a/packages/kosong/test/providers/pythinker-schema.test.ts b/packages/kosong/test/providers/pythinker-schema.test.ts index c230ea5c..f9402c6b 100644 --- a/packages/kosong/test/providers/pythinker-schema.test.ts +++ b/packages/kosong/test/providers/pythinker-schema.test.ts @@ -570,6 +570,106 @@ describe('normalizePythinkerToolSchema', () => { expect(normalizePythinkerToolSchema({})).toEqual({}); }); + it('drops a root anyOf that refines the root itself', () => { + // The TaskStop shape: `anyOf` says "one of these two fields is required". + // A tool's parameters must stay an object, so the branch form is unusable + // here and the requirement falls back to the tool's own runtime check. + const properties = { + task_id: { type: 'string' }, + shell_id: { type: 'string' }, + }; + + const result = normalizePythinkerToolSchema({ + type: 'object', + description: 'Root doc.', + properties, + additionalProperties: false, + anyOf: [{ required: ['task_id'] }, { required: ['shell_id'] }], + }); + + expect(result).toEqual({ + type: 'object', + description: 'Root doc.', + properties, + additionalProperties: false, + }); + }); + + it('keeps the arguments of a root anyOf whose branches hold the properties', () => { + const result = normalizePythinkerToolSchema({ + anyOf: [ + { type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] }, + { type: 'object', properties: { shell_id: { type: 'string' } }, required: ['shell_id'] }, + ], + }); + + expect(result).toEqual({ + type: 'object', + properties: { + task_id: { type: 'string' }, + shell_id: { type: 'string' }, + }, + }); + }); + + it('distributes into nested anyOf nodes and keeps narrower branch keywords', () => { + const result = normalizePythinkerToolSchema({ + type: 'object', + properties: { + target: { + type: 'array', + items: { type: 'string' }, + anyOf: [{ minItems: 1 }, { items: { type: 'integer' } }], + }, + }, + }); + + expect(result).toEqual({ + type: 'object', + properties: { + target: { + anyOf: [ + { type: 'array', items: { type: 'string' }, minItems: 1 }, + { type: 'array', items: { type: 'integer' } }, + ], + }, + }, + }); + }); + + it('leaves anyOf nodes alone when the parent only carries metadata', () => { + const schema = { + type: 'object', + properties: { + qs: { + description: 'A query, or a list of them.', + anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + }, + }; + + expect(normalizePythinkerToolSchema(schema)).toEqual(schema); + }); + + it('keeps cyclic $ref and definition buckets on the parent of an anyOf', () => { + const result = normalizePythinkerToolSchema({ + type: 'object', + properties: { + node: { + anyOf: [{ $ref: '#/$defs/Node' }, { type: 'null' }], + }, + }, + $defs: { + Node: { + type: 'object', + properties: { next: { $ref: '#/$defs/Node' } }, + }, + }, + }); + + expect(result['$defs']).toBeDefined(); + }); + it('dereferences and normalizes local definition buckets', () => { const schema = { type: 'object', @@ -755,42 +855,48 @@ describe('normalizePythinkerToolSchema', () => { it('preserves combinators while normalizing their schema branches', () => { const schema = { - anyOf: [{ enum: ['auto', 'manual'] }, { const: false }], - oneOf: [ - { - properties: { - strategy: { enum: ['replace', 'insert'] }, - }, - }, - ], - allOf: [ - { - items: { const: 1 }, + properties: { + combined: { + anyOf: [{ enum: ['auto', 'manual'] }, { const: false }], + oneOf: [ + { + properties: { + strategy: { enum: ['replace', 'insert'] }, + }, + }, + ], + allOf: [ + { + items: { const: 1 }, + }, + ], }, - ], + }, }; const result = normalizePythinkerToolSchema(schema); - expect(result).toEqual({ - anyOf: [ - { enum: ['auto', 'manual'], type: 'string' }, - { const: false, type: 'boolean' }, - ], - oneOf: [ - { - type: 'object', - properties: { - strategy: { enum: ['replace', 'insert'], type: 'string' }, + expect(result['properties']).toEqual({ + combined: { + anyOf: [ + { enum: ['auto', 'manual'], type: 'string' }, + { const: false, type: 'boolean' }, + ], + oneOf: [ + { + type: 'object', + properties: { + strategy: { enum: ['replace', 'insert'], type: 'string' }, + }, }, - }, - ], - allOf: [ - { - type: 'array', - items: { const: 1, type: 'integer' }, - }, - ], + ], + allOf: [ + { + type: 'array', + items: { const: 1, type: 'integer' }, + }, + ], + }, }); expect(result).not.toHaveProperty('type'); }); From 727d9d73d5329587972ddcd599de60f829449e06 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 4 Aug 2026 13:52:12 -0400 Subject: [PATCH 2/2] fix: address PR review findings --- .../kosong/src/providers/pythinker-schema.ts | 92 +++++++++++++++++-- .../test/providers/pythinker-schema.test.ts | 82 +++++++++++++++-- 2 files changed, 156 insertions(+), 18 deletions(-) diff --git a/packages/kosong/src/providers/pythinker-schema.ts b/packages/kosong/src/providers/pythinker-schema.ts index 67febf9b..31848b9f 100644 --- a/packages/kosong/src/providers/pythinker-schema.ts +++ b/packages/kosong/src/providers/pythinker-schema.ts @@ -157,24 +157,56 @@ function foldRootAnyOf(root: Record): void { delete root['anyOf']; const rootProperties = root['properties']; - const merged: Record = isRecord(rootProperties) - ? rootProperties - : {}; + const alternativesByName = new Map(); + if (isRecord(rootProperties)) { + for (const [name, property] of Object.entries(rootProperties)) { + alternativesByName.set(name, [cloneJsonValue(property)]); + } + } for (const branch of branches) { const branchProperties = branch['properties']; if (!isRecord(branchProperties)) continue; for (const [name, property] of Object.entries(branchProperties)) { - if (!hasOwn(merged, name)) { - merged[name] = cloneJsonValue(property); - } + addRootPropertyAlternative(alternativesByName, name, property); } } - if (Object.keys(merged).length > 0) { + + if (alternativesByName.size > 0) { + const merged: Record = {}; + for (const [name, alternatives] of alternativesByName) { + merged[name] = alternatives.length === 1 ? alternatives[0] : { anyOf: alternatives }; + } root['properties'] = merged; } root['type'] = 'object'; } +/** + * Record one branch's schema for a merged root property. + * + * Root `anyOf` branches are alternatives, so two branches declaring the same + * property with different schemas (e.g. `value` as a string in one branch, an + * integer in another) must both stay representable — keeping only the first + * one seen would silently narrow what the tool actually accepts. Identical + * schemas collapse to one; differing schemas fold into an `anyOf` on the + * merged property. + */ +function addRootPropertyAlternative( + alternativesByName: Map, + name: string, + property: unknown, +): void { + const cloned = cloneJsonValue(property); + const alternatives = alternativesByName.get(name); + if (!alternatives) { + alternativesByName.set(name, [cloned]); + return; + } + if (!alternatives.some((existing) => deepEqualJson(existing, cloned))) { + alternatives.push(cloned); + } +} + function hasUnresolvedDefinitionRef(node: unknown, bucketKey: string): boolean { if (Array.isArray(node)) { return node.some((child) => hasUnresolvedDefinitionRef(child, bucketKey)); @@ -337,8 +369,13 @@ const ANYOF_PARENT_KEEP_KEYS = new Set([ * so schemas that are perfectly valid elsewhere are rejected on this wire. * * Distributing is lossless: `P ∧ (B₁ ∨ B₂)` and `(P ∧ B₁) ∨ (P ∧ B₂)` accept - * exactly the same instances. A branch that already declares a keyword keeps - * its own, which is the narrower of the two. + * exactly the same instances — *if* a branch that already declares the same + * keyword is merged conjunctively with the parent's value rather than simply + * overriding it. `required` is the one keyword this function merges that way + * (parent and branch field lists are unioned, since both are actually + * required). Every other overlapping keyword still keeps the branch's own + * value: a full conjunctive merge for arbitrary keywords (`properties`, + * `items`, …) is out of scope for this compatibility normalizer. */ function distributeAnyOfParentKeywords(node: Record): void { const branches = node['anyOf']; @@ -355,6 +392,8 @@ function distributeAnyOfParentKeywords(node: Record): void { for (const key of inherited) { if (!hasOwn(branch, key)) { branch[key] = cloneJsonValue(node[key]); + } else if (key === 'required') { + branch[key] = mergeRequired(node[key], branch[key]); } } } @@ -363,6 +402,26 @@ function distributeAnyOfParentKeywords(node: Record): void { } } +/** + * Union two `required` field lists. + * + * A parent's `required` and a branch's own `required` are both mandatory — + * dropping the parent's list when the branch already has one would silently + * accept objects missing a field the parent demanded. + */ +function mergeRequired(parentValue: unknown, branchValue: unknown): unknown { + if (!Array.isArray(parentValue) || !Array.isArray(branchValue)) { + return branchValue; + } + const merged = [...branchValue]; + for (const name of parentValue) { + if (!merged.includes(name)) { + merged.push(name); + } + } + return merged; +} + function visitChildSchemas(node: Record, visit: (schema: unknown) => void): void { for (const { key, kind } of CHILD_SCHEMA_SLOTS) { const value = node[key]; @@ -574,6 +633,21 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function deepEqualJson(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => deepEqualJson(item, b[index])); + } + if (isRecord(a) && isRecord(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return aKeys.length === bKeys.length && aKeys.every((key) => hasOwn(b, key) && deepEqualJson(a[key], b[key])); + } + return false; +} + function hasOwn(obj: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(obj, key); } diff --git a/packages/kosong/test/providers/pythinker-schema.test.ts b/packages/kosong/test/providers/pythinker-schema.test.ts index f9402c6b..26ed143f 100644 --- a/packages/kosong/test/providers/pythinker-schema.test.ts +++ b/packages/kosong/test/providers/pythinker-schema.test.ts @@ -612,6 +612,40 @@ describe('normalizePythinkerToolSchema', () => { }); }); + it('folds a root anyOf property declared differently across branches into an alternative', () => { + const result = normalizePythinkerToolSchema({ + anyOf: [ + { type: 'object', properties: { value: { type: 'string' } } }, + { type: 'object', properties: { value: { type: 'integer' } } }, + ], + }); + + expect(result).toEqual({ + type: 'object', + properties: { + value: { anyOf: [{ type: 'string' }, { type: 'integer' }] }, + }, + }); + }); + + it('collapses a root anyOf property that is identical across every branch', () => { + const result = normalizePythinkerToolSchema({ + anyOf: [ + { type: 'object', properties: { mode: { type: 'string' }, task_id: { type: 'string' } } }, + { type: 'object', properties: { mode: { type: 'string' }, shell_id: { type: 'string' } } }, + ], + }); + + expect(result).toEqual({ + type: 'object', + properties: { + mode: { type: 'string' }, + task_id: { type: 'string' }, + shell_id: { type: 'string' }, + }, + }); + }); + it('distributes into nested anyOf nodes and keeps narrower branch keywords', () => { const result = normalizePythinkerToolSchema({ type: 'object', @@ -637,6 +671,31 @@ describe('normalizePythinkerToolSchema', () => { }); }); + it('unions a parent required list with a branch that already declares its own', () => { + const result = normalizePythinkerToolSchema({ + type: 'object', + properties: { + variant: { + type: 'object', + required: ['common'], + anyOf: [{ required: ['variant'] }, { required: ['common'] }], + }, + }, + }); + + expect(result).toEqual({ + type: 'object', + properties: { + variant: { + anyOf: [ + { type: 'object', required: ['variant', 'common'] }, + { type: 'object', required: ['common'] }, + ], + }, + }, + }); + }); + it('leaves anyOf nodes alone when the parent only carries metadata', () => { const schema = { type: 'object', @@ -651,23 +710,28 @@ describe('normalizePythinkerToolSchema', () => { expect(normalizePythinkerToolSchema(schema)).toEqual(schema); }); - it('keeps cyclic $ref and definition buckets on the parent of an anyOf', () => { + it('keeps $ref and $defs on the anyOf parent instead of distributing them into branches', () => { + // $ref must be genuinely cyclic (self-referential) to survive derefJsonSchema + // and still be present by the time distributeAnyOfParentKeywords runs. const result = normalizePythinkerToolSchema({ type: 'object', properties: { node: { - anyOf: [{ $ref: '#/$defs/Node' }, { type: 'null' }], - }, - }, - $defs: { - Node: { - type: 'object', - properties: { next: { $ref: '#/$defs/Node' } }, + $ref: '#/properties/node', + $defs: { Extra: { type: 'string' } }, + anyOf: [{ type: 'null' }], }, }, }); - expect(result['$defs']).toBeDefined(); + const node = (result['properties'] as Record)['node'] as Record; + expect(node['$ref']).toBe('#/properties/node'); + expect(node['$defs']).toEqual({ Extra: { type: 'string' } }); + const branches = node['anyOf'] as Record[]; + for (const branch of branches) { + expect(branch).not.toHaveProperty('$ref'); + expect(branch).not.toHaveProperty('$defs'); + } }); it('dereferences and normalizes local definition buckets', () => {