diff --git a/.changeset/fix-kimi-required-arrays.md b/.changeset/fix-kimi-required-arrays.md new file mode 100644 index 0000000000..2f3976c860 --- /dev/null +++ b/.changeset/fix-kimi-required-arrays.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Kimi-compatible APIs rejecting tool schemas whose object nodes omit required arrays. diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts index 90406e9170..b3737df729 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts +++ b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts @@ -2,8 +2,9 @@ * `kosong/provider` domain (L2) — Kimi tool-schema dialect normalization. * * Pure functions: dereference local `$ref` pointers by inlining definitions, - * then complete missing `type` fields from enum/const values or structural - * keys — the schema dialect the Kimi tool endpoint accepts. + * complete missing `type` fields from enum/const values or structural keys, + * and add missing `required` arrays to object nodes — the schema dialect the + * Kimi tool endpoint accepts. * * Circular references are detected and left as `$ref` to avoid infinite * recursion; in that case the referenced definition bucket is preserved so the @@ -227,6 +228,10 @@ function recurseSchema(node: unknown): void { return; } + if (node['type'] === 'object' && !hasOwn(node, 'required')) { + node['required'] = []; + } + visitChildSchemas(node, normalizeProperty); } diff --git a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts index d3a690eef8..397930a324 100644 --- a/packages/agent-core-v2/test/kosong/provider/kimi.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/kimi.test.ts @@ -69,6 +69,44 @@ describe('kimiOpenAITrait.convertTool', () => { }, }); }); + + it('adds required arrays to root and nested object schemas', () => { + const tool: Tool = { + name: 'read_file', + description: 'read', + parameters: { + type: 'object', + properties: { + options: { + type: 'object', + properties: { encoding: { type: 'string' } }, + }, + path: { type: 'string' }, + }, + required: ['path'], + }, + }; + + expect(convertKimiTool(tool)).toEqual({ + type: 'function', + function: { + name: 'read_file', + description: 'read', + parameters: { + type: 'object', + properties: { + options: { + type: 'object', + properties: { encoding: { type: 'string' } }, + required: [], + }, + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + }); + }); }); describe('kimiOpenAITrait.convertMessage', () => { diff --git a/packages/kosong/src/providers/kimi-schema.ts b/packages/kosong/src/providers/kimi-schema.ts index 93b428c642..1e7a17ea66 100644 --- a/packages/kosong/src/providers/kimi-schema.ts +++ b/packages/kosong/src/providers/kimi-schema.ts @@ -108,16 +108,16 @@ const NUMERIC_STRUCTURE_KEYS = new Set([ ]); /** - * Return a deep-cloned JSON Schema with missing `type` fields filled in for - * Kimi tool compatibility. + * Return a deep-cloned JSON Schema with missing `type` fields and object + * `required` arrays filled in for Kimi tool compatibility. * * Moonshot's tool validator rejects some valid JSON Schema shapes when nested * property schemas omit `type` (for example enum-only MCP properties). This is * a provider-compatibility normalizer, not a complete JSON Schema compiler: * 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. + * typeless property schemas. The root schema is not type-inferred, but an + * explicitly typed root object receives the same `required` normalization. */ export function normalizeKimiToolSchema(schema: Record): Record { return ensureKimiPropertyTypes(derefJsonSchema(schema)); @@ -252,6 +252,10 @@ function recurseSchema(node: unknown): void { return; } + if (node['type'] === 'object' && !hasOwn(node, 'required')) { + node['required'] = []; + } + visitChildSchemas(node, normalizeProperty); } diff --git a/packages/kosong/test/kimi.test.ts b/packages/kosong/test/kimi.test.ts index 12d8983969..b57c6d5c16 100644 --- a/packages/kosong/test/kimi.test.ts +++ b/packages/kosong/test/kimi.test.ts @@ -359,6 +359,7 @@ describe('KimiChatProvider', () => { prefixItems: [{ enum: ['left', 'right'], type: 'string' }], }, }, + required: [], }, }, }, diff --git a/packages/kosong/test/providers/kimi-schema.test.ts b/packages/kosong/test/providers/kimi-schema.test.ts index 02bdf5834f..d84781fbff 100644 --- a/packages/kosong/test/providers/kimi-schema.test.ts +++ b/packages/kosong/test/providers/kimi-schema.test.ts @@ -279,6 +279,141 @@ describe('derefJsonSchema', () => { }); describe('normalizeKimiToolSchema', () => { + it('adds empty required arrays to object schemas without mutating the input', () => { + const schema = { + type: 'object', + properties: { + nested: { + properties: { + value: { type: 'string' }, + }, + }, + list: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + }, + }, + }, + choice: { + allOf: [ + { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + }, + ], + anyOf: [ + { + type: 'object', + properties: { + label: { type: 'string' }, + }, + }, + ], + oneOf: [ + { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + }, + ], + }, + retained: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + }; + const original = structuredClone(schema); + + const result = normalizeKimiToolSchema(schema); + + expect(result).toEqual({ + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + required: [], + }, + list: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + }, + required: [], + }, + }, + choice: { + allOf: [ + { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + required: [], + }, + ], + anyOf: [ + { + type: 'object', + properties: { + label: { type: 'string' }, + }, + required: [], + }, + ], + oneOf: [ + { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + }, + required: [], + }, + ], + }, + retained: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, + required: [], + }); + expect(schema).toEqual(original); + }); + + it.each(['name', null, { name: true }])('preserves invalid required value %j', (required) => { + expect(normalizeKimiToolSchema({ type: 'object', properties: {}, required })).toEqual({ + type: 'object', + properties: {}, + required, + }); + }); + + it('leaves non-object schemas unchanged', () => { + expect(normalizeKimiToolSchema({ type: 'string' })).toEqual({ type: 'string' }); + expect(normalizeKimiToolSchema({ type: 'array' })).toEqual({ type: 'array' }); + expect(normalizeKimiToolSchema({ oneOf: [{ type: 'string' }] })).toEqual({ + oneOf: [{ type: 'string' }], + }); + }); + it.each([ { name: 'string enum', @@ -348,6 +483,7 @@ describe('normalizeKimiToolSchema', () => { properties: { target: { ...property, type: expectedType }, }, + required: [], }); expect(schema).toEqual(original); expect(result).not.toBe(schema); @@ -369,6 +505,7 @@ describe('normalizeKimiToolSchema', () => { properties: { explicit: { type: 'string', enum: ['already-typed'] }, }, + required: [], }); }); @@ -403,6 +540,7 @@ describe('normalizeKimiToolSchema', () => { enum: ['move', 'copy'], }, }, + required: [], }); expect(warnSpy).not.toHaveBeenCalled(); } finally { @@ -425,6 +563,7 @@ describe('normalizeKimiToolSchema', () => { properties: { mode: { type: 'string', const: 'fast' }, }, + required: [], }); }); @@ -443,8 +582,9 @@ describe('normalizeKimiToolSchema', () => { expect(result).toEqual({ type: 'object', properties: { - bad: { type: 'object', enum: ['move', 1] }, + bad: { type: 'object', enum: ['move', 1], required: [] }, }, + required: [], }); }); @@ -464,11 +604,12 @@ describe('normalizeKimiToolSchema', () => { expect(result).toEqual({ type: 'object', properties: { - object_enum: { enum: [{ a: 1 }, { a: 2 }], type: 'object' }, + object_enum: { enum: [{ a: 1 }, { a: 2 }], type: 'object', required: [] }, array_enum: { enum: [[1, 2], [3]], type: 'array' }, - object_const: { const: { kind: 'default' }, type: 'object' }, + object_const: { const: { kind: 'default' }, type: 'object', required: [] }, array_const: { const: [], type: 'array' }, }, + required: [], }); }); @@ -538,6 +679,7 @@ describe('normalizeKimiToolSchema', () => { items: { const: 42, type: 'integer' }, }, }, + required: [], }, }, }, @@ -593,6 +735,7 @@ describe('normalizeKimiToolSchema', () => { mode: { enum: ['fast', 'safe'], type: 'string' }, retryCount: { const: 3, type: 'integer' }, }, + required: [], }); }); @@ -631,6 +774,7 @@ describe('normalizeKimiToolSchema', () => { }, propertyNames: { pattern: '^x-', type: 'string' }, additionalProperties: { const: false, type: 'boolean' }, + required: [], }, tuple: { type: 'array', @@ -644,18 +788,22 @@ describe('normalizeKimiToolSchema', () => { if: { type: 'object', properties: { kind: { const: 'file', type: 'string' } }, + required: [], }, [thenKeyword]: { type: 'object', properties: { path: { pattern: '^src/', type: 'string' } }, + required: [], }, else: { type: 'object', properties: { url: { format: 'uri', type: 'string' } }, + required: [], }, not: { type: 'object', properties: { blocked: { const: true, type: 'boolean' } }, + required: [], }, }, }, @@ -714,8 +862,10 @@ describe('normalizeKimiToolSchema', () => { properties: { value: { enum: ['file', 'url'], type: 'string' }, }, + required: [], }, }, + required: [], }, dependenciesOnly: { type: 'object', @@ -725,12 +875,15 @@ describe('normalizeKimiToolSchema', () => { properties: { enabled: { const: true, type: 'boolean' }, }, + required: [], }, }, + required: [], }, unevaluatedPropertiesOnly: { type: 'object', unevaluatedProperties: { enum: ['allowed'], type: 'string' }, + required: [], }, additionalItemsOnly: { type: 'array', @@ -747,6 +900,7 @@ describe('normalizeKimiToolSchema', () => { properties: { decoded: { enum: ['payload'], type: 'string' }, }, + required: [], }, }, }, @@ -783,6 +937,7 @@ describe('normalizeKimiToolSchema', () => { properties: { strategy: { enum: ['replace', 'insert'], type: 'string' }, }, + required: [], }, ], allOf: [