From 44969a335a532a1f8bd3ddeaa3445c0766e83da6 Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Thu, 6 Aug 2026 19:33:04 +0200 Subject: [PATCH 1/3] fix(agent-core): strip unsupported media for UNKNOWN capabilities downgradeUnsupportedMedia previously left image/video/audio parts intact when capability was UNKNOWN_CAPABILITY. Uncatalogued or custom OpenAI-compatible text-only models then received history with image_url parts, returned 400, and poisoned every subsequent turn (#2669). Honor image_in/video_in/audio_in flags for all known matrices including UNKNOWN (all false). Only skip when capability is undefined. Fixes #2669 --- .../agent-core/src/agent/turn/kosong-llm.ts | 9 ++++-- .../agent-core/test/agent/kosong-llm.test.ts | 30 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/agent-core/src/agent/turn/kosong-llm.ts b/packages/agent-core/src/agent/turn/kosong-llm.ts index 893ea13fd9..1623250d28 100644 --- a/packages/agent-core/src/agent/turn/kosong-llm.ts +++ b/packages/agent-core/src/agent/turn/kosong-llm.ts @@ -19,7 +19,6 @@ import { emptyUsage, generate as kosongGenerate, isRetryableGenerateError, - isUnknownCapability, type ChatProvider, type ContentPart, type GenerateCallbacks, @@ -301,7 +300,13 @@ export function downgradeUnsupportedMedia( messages: readonly Message[], capability: ModelCapability | undefined, ): Message[] { - if (capability === undefined || isUnknownCapability(capability)) return [...messages]; + // Undefined = host did not supply a capability matrix; leave history alone. + // UNKNOWN_CAPABILITY and any known matrix with image_in/video_in/audio_in + // false must strip those parts: replaying image_url to a text-only + // OpenAI-compatible provider returns 400 and poisons every subsequent turn + // (#2669). UNKNOWN was previously exempt, which left custom providers that + // declare no vision (or are uncatalogued) unprotected. + if (capability === undefined) return [...messages]; const dropImage = !capability.image_in; const dropVideo = !capability.video_in; const dropAudio = !capability.audio_in; diff --git a/packages/agent-core/test/agent/kosong-llm.test.ts b/packages/agent-core/test/agent/kosong-llm.test.ts index eee3388092..a625ef1961 100644 --- a/packages/agent-core/test/agent/kosong-llm.test.ts +++ b/packages/agent-core/test/agent/kosong-llm.test.ts @@ -440,12 +440,38 @@ describe('downgradeUnsupportedMedia', () => { expect(out[0]?.content).toEqual([imagePart, videoPart]); }); - it('does not downgrade for UNKNOWN_CAPABILITY or an undefined capability', () => { + it('does not downgrade when capability is undefined (host omitted matrix)', () => { const input = [mediaMessage([videoPart])]; - expect(downgradeUnsupportedMedia(input, UNKNOWN_CAPABILITY)[0]?.content).toEqual([videoPart]); expect(downgradeUnsupportedMedia(input, undefined)[0]?.content).toEqual([videoPart]); }); + it('strips media for UNKNOWN_CAPABILITY (image_in/video_in/audio_in are false)', () => { + // Custom OpenAI-compatible text-only providers are often uncatalogued; + // replaying image_url to them returns 400 and bricks the session (#2669). + const input = [mediaMessage([imagePart, videoPart, audioPart])]; + expect(downgradeUnsupportedMedia(input, UNKNOWN_CAPABILITY)[0]?.content).toEqual([ + { type: 'text', text: '[image omitted: current model has no image input]' }, + { type: 'text', text: '[video omitted: current model has no video input]' }, + { type: 'text', text: '[audio omitted: current model has no audio input]' }, + ]); + }); + + it('strips images when capabilities declare thinking/tool_use but not image_in', () => { + const capability: ModelCapability = { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 128_000, + }; + const input = [mediaMessage([{ type: 'text', text: 'see this' }, imagePart])]; + expect(downgradeUnsupportedMedia(input, capability)[0]?.content).toEqual([ + { type: 'text', text: 'see this' }, + { type: 'text', text: '[image omitted: current model has no image input]' }, + ]); + }); + it('returns a new array and never mutates the caller input', () => { const capability = makeCapability(1000); // all media dropped const message = mediaMessage([videoPart]); From ef533276a5a1876a10287d960fabc0c186c80799 Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Thu, 6 Aug 2026 19:38:25 +0200 Subject: [PATCH 2/3] chore: add changeset for media strip on UNKNOWN capability Document the user-facing CLI patch for stripping unsupported media when the model capability matrix has no media input (including uncatalogued custom providers). --- .changeset/strip-unknown-media.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/strip-unknown-media.md diff --git a/.changeset/strip-unknown-media.md b/.changeset/strip-unknown-media.md new file mode 100644 index 0000000000..bd431c4d85 --- /dev/null +++ b/.changeset/strip-unknown-media.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Strip image, video, and audio history parts when the model has no media input capability, including uncatalogued custom providers that previously failed every later turn with a provider 400. From 5229cd29a3b267b73a3a7b7fde3bd0151670e47e Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Thu, 6 Aug 2026 19:51:02 +0200 Subject: [PATCH 3/3] fix(agent-core): preserve media under UNKNOWN_CAPABILITY Codex review: UNKNOWN means uncatalogued, not text-only. Multimodal custom models and SingleModelProvider defaults use that marker; stripping would drop legitimate attachments. Keep stripping only for explicit capability matrices with image_in / video_in / audio_in false (ProviderManager-resolved declared caps, #2669). --- .changeset/strip-unknown-media.md | 5 ----- .../agent-core/src/agent/turn/kosong-llm.ts | 21 ++++++++++++------- .../agent-core/test/agent/kosong-llm.test.ts | 16 +++++++------- 3 files changed, 23 insertions(+), 19 deletions(-) delete mode 100644 .changeset/strip-unknown-media.md diff --git a/.changeset/strip-unknown-media.md b/.changeset/strip-unknown-media.md deleted file mode 100644 index bd431c4d85..0000000000 --- a/.changeset/strip-unknown-media.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Strip image, video, and audio history parts when the model has no media input capability, including uncatalogued custom providers that previously failed every later turn with a provider 400. diff --git a/packages/agent-core/src/agent/turn/kosong-llm.ts b/packages/agent-core/src/agent/turn/kosong-llm.ts index 1623250d28..86dc14f1cf 100644 --- a/packages/agent-core/src/agent/turn/kosong-llm.ts +++ b/packages/agent-core/src/agent/turn/kosong-llm.ts @@ -19,6 +19,7 @@ import { emptyUsage, generate as kosongGenerate, isRetryableGenerateError, + isUnknownCapability, type ChatProvider, type ContentPart, type GenerateCallbacks, @@ -300,13 +301,19 @@ export function downgradeUnsupportedMedia( messages: readonly Message[], capability: ModelCapability | undefined, ): Message[] { - // Undefined = host did not supply a capability matrix; leave history alone. - // UNKNOWN_CAPABILITY and any known matrix with image_in/video_in/audio_in - // false must strip those parts: replaying image_url to a text-only - // OpenAI-compatible provider returns 400 and poisons every subsequent turn - // (#2669). UNKNOWN was previously exempt, which left custom providers that - // declare no vision (or are uncatalogued) unprotected. - if (capability === undefined) return [...messages]; + // Leave history alone when the host omitted a matrix (undefined) or only + // knows the model is uncatalogued (UNKNOWN_CAPABILITY). UNKNOWN means "we + // do not know", not "text-only" — a multimodal custom model can still use + // that marker (SingleModelProvider default, KimiForCodingProvider). + // + // Explicit matrices still strip: ProviderManager merges declared + // capabilities with the catalog into a plain ModelCapability object, so a + // text-only custom model configured as capabilities = ["thinking", + // "tool_use"] gets image_in: false without the UNKNOWN marker and is + // protected against image_url 400s that brick later turns (#2669). + if (capability === undefined || isUnknownCapability(capability)) { + return [...messages]; + } const dropImage = !capability.image_in; const dropVideo = !capability.video_in; const dropAudio = !capability.audio_in; diff --git a/packages/agent-core/test/agent/kosong-llm.test.ts b/packages/agent-core/test/agent/kosong-llm.test.ts index a625ef1961..cf0dd8a77f 100644 --- a/packages/agent-core/test/agent/kosong-llm.test.ts +++ b/packages/agent-core/test/agent/kosong-llm.test.ts @@ -445,18 +445,20 @@ describe('downgradeUnsupportedMedia', () => { expect(downgradeUnsupportedMedia(input, undefined)[0]?.content).toEqual([videoPart]); }); - it('strips media for UNKNOWN_CAPABILITY (image_in/video_in/audio_in are false)', () => { - // Custom OpenAI-compatible text-only providers are often uncatalogued; - // replaying image_url to them returns 400 and bricks the session (#2669). + it('preserves media for UNKNOWN_CAPABILITY (uncatalogued ≠ text-only)', () => { + // UNKNOWN is the SingleModelProvider / unresolved default: the model may + // still be multimodal. Explicit text-only matrices are covered below. const input = [mediaMessage([imagePart, videoPart, audioPart])]; expect(downgradeUnsupportedMedia(input, UNKNOWN_CAPABILITY)[0]?.content).toEqual([ - { type: 'text', text: '[image omitted: current model has no image input]' }, - { type: 'text', text: '[video omitted: current model has no video input]' }, - { type: 'text', text: '[audio omitted: current model has no audio input]' }, + imagePart, + videoPart, + audioPart, ]); }); - it('strips images when capabilities declare thinking/tool_use but not image_in', () => { + it('strips images when an explicit matrix has no image_in (#2669)', () => { + // ProviderManager resolves declared capabilities into a plain object + // (not the UNKNOWN marker), e.g. capabilities = ["thinking", "tool_use"]. const capability: ModelCapability = { image_in: false, video_in: false,