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 050b65e749..004d8a038a 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -23,6 +23,7 @@ import { isImageFormatError, isRecoverableRequestStructureError, isRetryableGenerateError, + isUnsupportedContentTypeError, } from '#/kosong/contract/errors'; import { isToolCall, type Message, type StreamedMessagePart } from '#/kosong/contract/message'; import { type ThinkingEffort } from '#/kosong/contract/provider'; @@ -487,6 +488,17 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { ); return { ...policy, media: captureMediaStripPolicy() }; } + if (typeof media !== 'object' && 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, + }, + ); + return { ...policy, media: captureMediaStripPolicy() }; + } if (policy?.structure === undefined && 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 e2db1f28a3..14d3769419 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/kosong/contract/errors.ts @@ -432,6 +432,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/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index fc208f4374..bcdded8959 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 @@ -702,6 +702,33 @@ describe('projector tool-exchange normalization', () => { }; } + it('applies strict repairs alongside media stripping', () => { + const history = [ + user('go'), + imageMessage('data:image/png;base64,AAAA'), + assistant('', ['c1']), + toolResult('c1', 'one'), + assistant('', ['c1']), + toolResult('c1', 'two'), + ]; + const snapshot = projector.captureMediaStripSnapshot(history); + const projected = projector.project(history, { + structure: 'strict', + media: { strip: snapshot }, + }); + + 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); + }); + function projectStripped( history: readonly ContextMessage[], snapshot = projector.captureMediaStripSnapshot(history), 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 032ef37fd8..ee55d5e5fc 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -391,6 +391,71 @@ describe('AgentLLMRequesterService media-stripped resend', () => { expect(calls.value).toBe(1); expect(projection.calls).toEqual(['normal']); }); + + 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 }; + const projection = recordProjectionCalls(); + const { service } = createService(createRequester(calls, CONTENT_TYPE_400), projection.projector); + + const result = await service.request(); + + expect(result.message.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(calls.value).toBe(2); + expect(projection.calls).toEqual(['normal', 'stripped']); + }); + + 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', () => {