From 029f9276fe5d3e307cf054e5d3da4795a6a7368c Mon Sep 17 00:00:00 2001 From: jekhy Date: Mon, 17 Aug 2026 11:22:46 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): retry with media-stripped projection on content-type-invalid 400 A text-only model rejects media content blocks in history with a 400 'content.type is invalid' error, breaking the conversation after switching from a multimodal model. Detect the error via isUnsupportedContentTypeError and resend with the media-stripped projection, mirroring the existing image-format recovery path. --- .changeset/fix-text-model-content-type.md | 5 ++++ .../agent/llmRequester/llmRequesterService.ts | 15 ++++++++++ .../src/kosong/contract/errors.ts | 15 ++++++++++ .../llmRequester/llmRequesterService.test.ts | 30 ++++++++++++++++++- 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-text-model-content-type.md diff --git a/.changeset/fix-text-model-content-type.md b/.changeset/fix-text-model-content-type.md new file mode 100644 index 0000000000..771990af45 --- /dev/null +++ b/.changeset/fix-text-model-content-type.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix conversations breaking after switching from a multimodal model to a text-only model. Media content blocks in history are now automatically stripped and retried when the text-only model rejects them. diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 92339a83df..6d56d17c8c 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -54,6 +54,7 @@ import { isImageFormatError, isRecoverableRequestStructureError, isRetryableGenerateError, + isUnsupportedContentTypeError, } from '#/kosong/contract/errors'; import { type Message } from '#/kosong/contract/message'; import { type ThinkingEffort } from '#/kosong/contract/provider'; @@ -499,6 +500,20 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { projection = 'media-stripped'; continue; } + if (projection !== 'media-stripped' && isUnsupportedContentTypeError(raw)) { + signal?.throwIfAborted(); + this.log.warn( + 'provider does not support non-text content types; resending with media stripped', + { + model: request.model.name, + ...request.logFields, + }, + ); + mediaStripSnapshot = this.projector.captureMediaStripSnapshot(shaped); + this.markMediaStrippedRecoveryTurn(mediaStripSnapshot, request.source); + projection = 'media-stripped'; + continue; + } if (projection === 'normal' && isRecoverableRequestStructureError(raw)) { signal?.throwIfAborted(); this.log.warn('provider rejected request structure; resending with strict projection', { diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/kosong/contract/errors.ts index bea23797a8..914ef26375 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -457,6 +457,21 @@ export function isRecoverableRequestStructureError(error: unknown): boolean { return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } +const CONTENT_TYPE_INVALID_MESSAGE_PATTERNS = [ + /messages\.content\.type is invalid/, + /content\[\d*\]\.type .*invalid/, + /allowed values.*\['?text'?\]/, +] as const; + +export function isUnsupportedContentTypeError(error: unknown): boolean { + if (!(error instanceof APIStatusError)) return false; + if (error instanceof APIContextOverflowError) return false; + if (error instanceof APIRequestTooLargeError) return false; + if (error.statusCode !== 400) return false; + const lowerMessage = error.message.toLowerCase(); + return CONTENT_TYPE_INVALID_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + export function isProviderRateLimitError(error: unknown): boolean { if (error instanceof APIProviderQuotaExhaustedError) return false; if (error instanceof APIProviderRateLimitError) return true; diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index a58079cc87..1b3c49d8d0 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -2,7 +2,7 @@ * Scenario: LLM requester uses bounded recovery projections after a * deterministic provider rejection — strict projection for tool-use * adjacency, degraded media followed by full stripping for body-size 413s, - * and media stripping for image-format rejections. + * and media stripping for image-format and content-type rejections. * * Responsibilities: assert retry eligibility, projection order and bounds, * per-turn recovery stickiness, request recording, and usage accounting. @@ -426,6 +426,34 @@ describe('AgentLLMRequesterService media-stripped resend', () => { expect(calls.value).toBe(1); expect(strippedCalls).toBe(0); }); + + it('resends with media-stripped after a content-type-invalid 400 (non-text model)', async () => { + const CONTENT_TYPE_400 = new APIStatusError( + 400, + "messages.content.type is invalid, allowed values: ['text']", + ); + const calls = { value: 0 }; + let projectCalls = 0; + let strippedCalls = 0; + const { service } = createService(createRequester(calls, CONTENT_TYPE_400), { + project: (messages: readonly ContextMessage[]) => { + projectCalls += 1; + return messages; + }, + projectStrict: (messages: readonly ContextMessage[]) => messages, + projectMediaStripped: (messages: readonly ContextMessage[]) => { + strippedCalls += 1; + return messages; + }, + }); + + const result = await service.request(); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(calls.value).toBe(2); + expect(projectCalls).toBe(1); + expect(strippedCalls).toBe(1); + }); }); describe('AgentLLMRequesterService media-degraded resend', () => { From 37c770d90435ae5b294647c407a339884e44b447 Mon Sep 17 00:00:00 2001 From: jekhy Date: Mon, 17 Aug 2026 14:44:15 +0800 Subject: [PATCH 2/2] fix(agent-core-v2): compose strict repairs into the media-stripped projection A media-stripped resend rebuilt the request from the normal projection, dropping the structural repairs a preceding strict resend had applied: a history needing both (e.g. duplicate tool-use ids plus media) re-introduced the structural defect and could not recover, since the structural retry is gated on the normal projection. Rebuild media-stripped on the strict projection so the repairs ride along; healthy histories are unaffected because strict repairs only fire on anomalous history. --- .../contextProjectorService.ts | 12 +++-- .../projector-tool-exchanges.test.ts | 22 +++++++++ .../llmRequester/llmRequesterService.test.ts | 49 +++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index ca92b6205c..2909a31293 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -19,10 +19,12 @@ * projections for the two deterministic provider rejections: media-degraded * (all but the most recent media replaced by text markers) resends after an * HTTP 413 body-size rejection; media-stripped captures every media identity - * present when degraded media is still too large or an image format is - * rejected, then replaces only that snapshot on later steps so a newly - * generated recovery image remains visible. Both are read-side only — the - * history keeps its media. + * present when degraded media is still too large or an image format / + * content-type is rejected, then replaces only that snapshot on later steps + * so a newly generated recovery image remains visible. Media-stripped + * rebuilds on the strict projection, so structural repairs ride along when a + * conversation needs both. Both are read-side only — the history keeps its + * media. */ import { createHash } from 'node:crypto'; @@ -89,7 +91,7 @@ export class AgentContextProjectorService implements IAgentContextProjectorServi messages: readonly ContextMessage[], snapshot?: MediaStripSnapshot, ): readonly Message[] { - const projected = this.projectWithTrace(messages, project); + const projected = this.projectWithTrace(messages, projectStrict); return stripMediaPartsBySnapshot( projected, snapshot ?? captureMediaStripSnapshot(projected), diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index ad501f1c08..5187640d1c 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -709,6 +709,28 @@ describe('projector tool-exchange normalization', () => { }; } + it('applies strict repairs alongside media stripping', () => { + const projected = projector.projectMediaStripped([ + user('go'), + imageMessage('data:image/png;base64,AAAA'), + assistant('', ['c1']), + toolResult('c1', 'one'), + assistant('', ['c1']), + toolResult('c1', 'two'), + ]); + + const toolCallIds = projected.flatMap((message) => message.toolCalls.map((call) => call.id)); + expect(toolCallIds).toEqual(['c1']); + const parts = projected.flatMap((message) => message.content); + expect(parts.some((part) => part.type === 'image_url')).toBe(false); + expect( + parts.some( + (part) => + part.type === 'text' && part.text.includes('omitted for provider compatibility'), + ), + ).toBe(true); + }); + it('replaces every media part with a text marker, keeping the surrounding text', () => { const projected = projector.projectMediaStripped([ user('look at these'), diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index 7f412ad803..b91140f099 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -461,6 +461,55 @@ describe('AgentLLMRequesterService media-stripped resend', () => { expect(projectCalls).toBe(1); expect(strippedCalls).toBe(1); }); + + it('keeps strict repairs in the media-stripped resend after a structural then content-type rejection', async () => { + const STRUCTURAL_400 = new APIStatusError(400, 'messages: `tool_use` ids must be unique'); + const CONTENT_TYPE_400 = new APIStatusError( + 400, + "messages.content.type is invalid, allowed values: ['text']", + ); + const historyWithDuplicateCallsAndMedia: Message[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ], + toolCalls: [], + }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }], + }, + { role: 'tool', content: [{ type: 'text', text: 'one' }], toolCalls: [], toolCallId: 'c1' }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{}' }], + }, + { role: 'tool', content: [{ type: 'text', text: 'two' }], toolCalls: [], toolCallId: 'c1' }, + ]; + const calls = { value: 0 }; + const capturedInputs: ModelRequestInput[] = []; + const { service } = createService( + createRequester(calls, STRUCTURAL_400, [CONTENT_TYPE_400], capturedInputs), + undefined, + { contextMessages: historyWithDuplicateCallsAndMedia }, + ); + + const result = await service.request(); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(calls.value).toBe(3); + const finalMessages = capturedInputs[2]!.messages; + expect( + finalMessages.flatMap((message) => message.content).some((part) => part.type === 'image_url'), + ).toBe(false); + expect(finalMessages.flatMap((message) => message.toolCalls.map((call) => call.id))).toEqual([ + 'c1', + ]); + }); }); describe('AgentLLMRequesterService media-degraded resend', () => {