Skip to content

Commit 329e0b5

Browse files
committed
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.
1 parent 5ec7157 commit 329e0b5

3 files changed

Lines changed: 250 additions & 31 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
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.

packages/kosong/src/providers/pythinker-schema.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ const NUMERIC_STRUCTURE_KEYS = new Set([
117117
* it resolves local refs, preserves combinator nodes, infers obvious
118118
* scalar/object/array types, and falls back to `string` only for nested
119119
* typeless property schemas. The root schema object is treated as a container
120-
* and is not itself normalized.
120+
* and is not itself type-normalized, with one exception: an `anyOf` at the root
121+
* is folded away, because a tool's parameters must be a plain object.
121122
*/
122123
export function normalizePythinkerToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
123124
return ensurePythinkerPropertyTypes(derefJsonSchema(schema));
@@ -128,10 +129,52 @@ function ensurePythinkerPropertyTypes(schema: Record<string, unknown>): Record<s
128129
if (!isRecord(normalized)) {
129130
throw new Error('JSON Schema root must normalize to an object.');
130131
}
132+
// Fold the root's `anyOf` away before recursing; once it is gone the generic
133+
// per-node distribution below sees nothing to do at the root.
134+
foldRootAnyOf(normalized);
131135
recurseSchema(normalized);
132136
return normalized;
133137
}
134138

139+
/**
140+
* Remove an `anyOf` sitting at the root of a tool's parameter schema.
141+
*
142+
* A tool's parameters must be an object, so the root cannot use the branch form
143+
* that {@link distributeAnyOfParentKeywords} produces for every other node: the
144+
* wire requires `type: "object"` there, and rejects `type` next to `anyOf`. The
145+
* two constraints are jointly unsatisfiable, so the root's `anyOf` is dropped.
146+
*
147+
* Dropping only ever widens what the schema accepts — a root `anyOf` is almost
148+
* always a "one of these fields is required" hint, which the tool re-checks when
149+
* it runs. Branch properties are folded into the root first, so a schema that
150+
* kept its arguments inside the branches does not lose them.
151+
*/
152+
function foldRootAnyOf(root: Record<string, unknown>): void {
153+
const branches = root['anyOf'];
154+
if (!Array.isArray(branches) || !branches.every(isRecord)) {
155+
return;
156+
}
157+
delete root['anyOf'];
158+
159+
const rootProperties = root['properties'];
160+
const merged: Record<string, unknown> = isRecord(rootProperties)
161+
? rootProperties
162+
: {};
163+
for (const branch of branches) {
164+
const branchProperties = branch['properties'];
165+
if (!isRecord(branchProperties)) continue;
166+
for (const [name, property] of Object.entries(branchProperties)) {
167+
if (!hasOwn(merged, name)) {
168+
merged[name] = cloneJsonValue(property);
169+
}
170+
}
171+
}
172+
if (Object.keys(merged).length > 0) {
173+
root['properties'] = merged;
174+
}
175+
root['type'] = 'object';
176+
}
177+
135178
function hasUnresolvedDefinitionRef(node: unknown, bucketKey: string): boolean {
136179
if (Array.isArray(node)) {
137180
return node.some((child) => hasUnresolvedDefinitionRef(child, bucketKey));
@@ -252,9 +295,74 @@ function recurseSchema(node: unknown): void {
252295
return;
253296
}
254297

298+
distributeAnyOfParentKeywords(node);
255299
visitChildSchemas(node, normalizeProperty);
256300
}
257301

302+
/**
303+
* Keywords that may stay on a schema node that also carries `anyOf`.
304+
*
305+
* Everything else is a validation keyword the wire validator refuses to see on
306+
* both sides of an `anyOf`. Sibling combinators are left alone because the
307+
* validator does not read them at all, so relocating them would only churn the
308+
* schema. `$defs` / `definitions` stay put because cyclic `$ref` pointers
309+
* resolve against the root, and `$ref` stays because duplicating a cyclic
310+
* reference into every branch changes its meaning.
311+
*/
312+
const ANYOF_PARENT_KEEP_KEYS = new Set([
313+
'$comment',
314+
'$defs',
315+
'$ref',
316+
'$schema',
317+
'allOf',
318+
'anyOf',
319+
'default',
320+
'definitions',
321+
'description',
322+
'else',
323+
'if',
324+
'not',
325+
'oneOf',
326+
'then',
327+
'title',
328+
]);
329+
330+
/**
331+
* Push a node's own constraints down into its `anyOf` branches.
332+
*
333+
* Pythoughts's tool validator rejects `anyOf` used as a refinement of its parent:
334+
* `type` must be declared inside the branches rather than beside them, and no
335+
* other validation keyword (`properties`, `items`, `additionalProperties`, …)
336+
* may appear on both the parent and a branch. Standard JSON Schema allows both,
337+
* so schemas that are perfectly valid elsewhere are rejected on this wire.
338+
*
339+
* Distributing is lossless: `P ∧ (B₁ ∨ B₂)` and `(P ∧ B₁) ∨ (P ∧ B₂)` accept
340+
* exactly the same instances. A branch that already declares a keyword keeps
341+
* its own, which is the narrower of the two.
342+
*/
343+
function distributeAnyOfParentKeywords(node: Record<string, unknown>): void {
344+
const branches = node['anyOf'];
345+
if (!Array.isArray(branches) || branches.length === 0 || !branches.every(isRecord)) {
346+
return;
347+
}
348+
349+
const inherited = Object.keys(node).filter((key) => !ANYOF_PARENT_KEEP_KEYS.has(key));
350+
if (inherited.length === 0) {
351+
return;
352+
}
353+
354+
for (const branch of branches) {
355+
for (const key of inherited) {
356+
if (!hasOwn(branch, key)) {
357+
branch[key] = cloneJsonValue(node[key]);
358+
}
359+
}
360+
}
361+
for (const key of inherited) {
362+
delete node[key];
363+
}
364+
}
365+
258366
function visitChildSchemas(node: Record<string, unknown>, visit: (schema: unknown) => void): void {
259367
for (const { key, kind } of CHILD_SCHEMA_SLOTS) {
260368
const value = node[key];

packages/kosong/test/providers/pythinker-schema.test.ts

Lines changed: 136 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,106 @@ describe('normalizePythinkerToolSchema', () => {
570570
expect(normalizePythinkerToolSchema({})).toEqual({});
571571
});
572572

573+
it('drops a root anyOf that refines the root itself', () => {
574+
// The TaskStop shape: `anyOf` says "one of these two fields is required".
575+
// A tool's parameters must stay an object, so the branch form is unusable
576+
// here and the requirement falls back to the tool's own runtime check.
577+
const properties = {
578+
task_id: { type: 'string' },
579+
shell_id: { type: 'string' },
580+
};
581+
582+
const result = normalizePythinkerToolSchema({
583+
type: 'object',
584+
description: 'Root doc.',
585+
properties,
586+
additionalProperties: false,
587+
anyOf: [{ required: ['task_id'] }, { required: ['shell_id'] }],
588+
});
589+
590+
expect(result).toEqual({
591+
type: 'object',
592+
description: 'Root doc.',
593+
properties,
594+
additionalProperties: false,
595+
});
596+
});
597+
598+
it('keeps the arguments of a root anyOf whose branches hold the properties', () => {
599+
const result = normalizePythinkerToolSchema({
600+
anyOf: [
601+
{ type: 'object', properties: { task_id: { type: 'string' } }, required: ['task_id'] },
602+
{ type: 'object', properties: { shell_id: { type: 'string' } }, required: ['shell_id'] },
603+
],
604+
});
605+
606+
expect(result).toEqual({
607+
type: 'object',
608+
properties: {
609+
task_id: { type: 'string' },
610+
shell_id: { type: 'string' },
611+
},
612+
});
613+
});
614+
615+
it('distributes into nested anyOf nodes and keeps narrower branch keywords', () => {
616+
const result = normalizePythinkerToolSchema({
617+
type: 'object',
618+
properties: {
619+
target: {
620+
type: 'array',
621+
items: { type: 'string' },
622+
anyOf: [{ minItems: 1 }, { items: { type: 'integer' } }],
623+
},
624+
},
625+
});
626+
627+
expect(result).toEqual({
628+
type: 'object',
629+
properties: {
630+
target: {
631+
anyOf: [
632+
{ type: 'array', items: { type: 'string' }, minItems: 1 },
633+
{ type: 'array', items: { type: 'integer' } },
634+
],
635+
},
636+
},
637+
});
638+
});
639+
640+
it('leaves anyOf nodes alone when the parent only carries metadata', () => {
641+
const schema = {
642+
type: 'object',
643+
properties: {
644+
qs: {
645+
description: 'A query, or a list of them.',
646+
anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
647+
},
648+
},
649+
};
650+
651+
expect(normalizePythinkerToolSchema(schema)).toEqual(schema);
652+
});
653+
654+
it('keeps cyclic $ref and definition buckets on the parent of an anyOf', () => {
655+
const result = normalizePythinkerToolSchema({
656+
type: 'object',
657+
properties: {
658+
node: {
659+
anyOf: [{ $ref: '#/$defs/Node' }, { type: 'null' }],
660+
},
661+
},
662+
$defs: {
663+
Node: {
664+
type: 'object',
665+
properties: { next: { $ref: '#/$defs/Node' } },
666+
},
667+
},
668+
});
669+
670+
expect(result['$defs']).toBeDefined();
671+
});
672+
573673
it('dereferences and normalizes local definition buckets', () => {
574674
const schema = {
575675
type: 'object',
@@ -755,42 +855,48 @@ describe('normalizePythinkerToolSchema', () => {
755855

756856
it('preserves combinators while normalizing their schema branches', () => {
757857
const schema = {
758-
anyOf: [{ enum: ['auto', 'manual'] }, { const: false }],
759-
oneOf: [
760-
{
761-
properties: {
762-
strategy: { enum: ['replace', 'insert'] },
763-
},
764-
},
765-
],
766-
allOf: [
767-
{
768-
items: { const: 1 },
858+
properties: {
859+
combined: {
860+
anyOf: [{ enum: ['auto', 'manual'] }, { const: false }],
861+
oneOf: [
862+
{
863+
properties: {
864+
strategy: { enum: ['replace', 'insert'] },
865+
},
866+
},
867+
],
868+
allOf: [
869+
{
870+
items: { const: 1 },
871+
},
872+
],
769873
},
770-
],
874+
},
771875
};
772876

773877
const result = normalizePythinkerToolSchema(schema);
774878

775-
expect(result).toEqual({
776-
anyOf: [
777-
{ enum: ['auto', 'manual'], type: 'string' },
778-
{ const: false, type: 'boolean' },
779-
],
780-
oneOf: [
781-
{
782-
type: 'object',
783-
properties: {
784-
strategy: { enum: ['replace', 'insert'], type: 'string' },
879+
expect(result['properties']).toEqual({
880+
combined: {
881+
anyOf: [
882+
{ enum: ['auto', 'manual'], type: 'string' },
883+
{ const: false, type: 'boolean' },
884+
],
885+
oneOf: [
886+
{
887+
type: 'object',
888+
properties: {
889+
strategy: { enum: ['replace', 'insert'], type: 'string' },
890+
},
785891
},
786-
},
787-
],
788-
allOf: [
789-
{
790-
type: 'array',
791-
items: { const: 1, type: 'integer' },
792-
},
793-
],
892+
],
893+
allOf: [
894+
{
895+
type: 'array',
896+
items: { const: 1, type: 'integer' },
897+
},
898+
],
899+
},
794900
});
795901
expect(result).not.toHaveProperty('type');
796902
});

0 commit comments

Comments
 (0)