From 93ec6cb6526021156a951f8c513c45f138bf5dbb Mon Sep 17 00:00:00 2001 From: Kai Date: Thu, 2 Jul 2026 13:52:44 +0800 Subject: [PATCH] fix(kosong): recognize OpenAI-compatible tool_call_id 400 as a recoverable tool-exchange error (#1292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moonshot / Kimi (OpenAI-compatible) rejects a history whose tool message references a tool_call_id with no matching tool_calls entry in the preceding assistant message as `400 tool_call_id is not found`. The TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS only covered Anthropic's tool_use/tool_result phrasing, so isRecoverableRequestStructureError returned false, the strict-resend fallback in executeLoopStep never fired, and the session stayed permanently stuck re-sending the same rejected history every turn (observed in the field after a manual compaction busted the prompt cache and forced full revalidation of a latently misordered prefix). Add the tool_call_id-anchored pattern so the whole recovery chain — strict projection (adjacency repair, orphan-result drop, synthetic results) plus the one-shot resend — now also covers the default provider. Covered by classifier unit tests and an e2e resend-and-recover case. --- .changeset/moonshot-tool-call-id-recovery.md | 5 ++++ .../loop/tool-exchange-fallback.e2e.test.ts | 17 +++++++++++ packages/kosong/src/errors.ts | 21 +++++++++---- packages/kosong/test/errors.test.ts | 30 +++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 .changeset/moonshot-tool-call-id-recovery.md diff --git a/.changeset/moonshot-tool-call-id-recovery.md b/.changeset/moonshot-tool-call-id-recovery.md new file mode 100644 index 000000000..c4648d863 --- /dev/null +++ b/.changeset/moonshot-tool-call-id-recovery.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kosong": patch +--- + +Recognize the OpenAI-compatible (Moonshot / Kimi) `tool_call_id ... is not found` 400 as a recoverable tool-exchange structural error, so the post-400 strict-resend fallback fires and un-bricks the session instead of failing every subsequent turn with the same error. diff --git a/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts b/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts index 6357cb424..67fe75d4c 100644 --- a/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts +++ b/packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts @@ -28,6 +28,10 @@ const ADJACENCY_400 = new APIStatusError( '`tool_result` block in the next message.', ); +// The OpenAI-compatible (Moonshot / Kimi) phrasing of the same tool-exchange +// structural rejection. Verbatim from the field, doubled space included. +const MOONSHOT_TOOL_CALL_ID_400 = new APIStatusError(400, '400 tool_call_id is not found'); + function userMessage(text: string): Message { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; } @@ -81,6 +85,19 @@ describe('executeLoopStep — tool exchange adjacency fallback', () => { expect(llm.calls[1]?.messages).toBe(strictMessages); }); + it('resends once and recovers after a Moonshot tool_call_id-not-found 400', async () => { + const { input, llm, strictCalls, strictMessages } = makeHarness(MOONSHOT_TOOL_CALL_ID_400); + + const result = await runTurn(input); + + expect(result.stopReason).toBe('end_turn'); + // Exactly two provider calls: the rejected one and the strict resend. + expect(llm.callCount).toBe(2); + expect(strictCalls.count).toBe(1); + expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]); + expect(llm.calls[1]?.messages).toBe(strictMessages); + }); + it('does not resend for an unrelated 400 — the error propagates and strict is untouched', async () => { const { input, llm, strictCalls } = makeHarness(new APIStatusError(400, 'Bad request')); diff --git a/packages/kosong/src/errors.ts b/packages/kosong/src/errors.ts index 49bbebaae..56d3fef9b 100644 --- a/packages/kosong/src/errors.ts +++ b/packages/kosong/src/errors.ts @@ -170,16 +170,25 @@ export function isContextOverflowStatusError(statusCode: number, message: string return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); } -// Strict providers (Anthropic) reject a request whose assistant `tool_use` and -// `tool_result` blocks are not correctly paired and adjacent — a missing result, -// a stray result with no matching call, or a result that does not immediately -// follow its call. The validation runs before any generation, so the error is a -// non-retryable 4xx. A caller can react by resending a re-projected, strictly -// wire-compliant request rather than leaving the session permanently stuck. +// Strict providers reject a request whose assistant `tool_use`/`tool_calls` and +// `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing +// result, a stray result with no matching call, or a result that does not +// immediately follow its call. Anthropic phrases this in terms of +// `tool_use`/`tool_result`; OpenAI-compatible providers (Moonshot / Kimi) phrase +// it as a `tool_call_id` that "is not found" in the preceding assistant message. +// The validation runs before any generation, so the error is a non-retryable +// 4xx. A caller can react by resending a re-projected, strictly wire-compliant +// request rather than leaving the session permanently stuck. const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ /tool_use[\s\S]*tool_result/, /tool_result[\s\S]*tool_use/, /unexpected\s+`?tool_result/, + // OpenAI-compatible (Moonshot / Kimi): a `tool` message references a + // `tool_call_id` with no matching `tool_calls` entry in the preceding + // assistant message. Observed verbatim as `tool_call_id is not found` + // (doubled space). Anchored on `tool_call_id` so an unrelated "not found" + // (e.g. a 404-style body) cannot trip the recovery. + /tool_call_id[\s\S]*not found/, ] as const; export function isToolExchangeAdjacencyError(error: unknown): boolean { diff --git a/packages/kosong/test/errors.test.ts b/packages/kosong/test/errors.test.ts index ad1f82431..e6b8bb132 100644 --- a/packages/kosong/test/errors.test.ts +++ b/packages/kosong/test/errors.test.ts @@ -246,11 +246,35 @@ describe('isToolExchangeAdjacencyError', () => { ); }); + // The exact OpenAI-compatible (Moonshot / Kimi) message observed in the field + // when a `tool` message's `tool_call_id` has no matching `tool_calls` entry in + // the preceding assistant message. The doubled space is verbatim from the + // provider. + const MOONSHOT_TOOL_CALL_ID_NOT_FOUND = '400 tool_call_id is not found'; + + it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { + expect( + isToolExchangeAdjacencyError(new APIStatusError(400, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)), + ).toBe(true); + expect( + isToolExchangeAdjacencyError(new APIStatusError(400, "tool_call_id 'call_abc123' is not found")), + ).toBe(true); + }); + + it('also matches a 422 tool_call_id-not-found', () => { + expect( + isToolExchangeAdjacencyError(new APIStatusError(422, MOONSHOT_TOOL_CALL_ID_NOT_FOUND)), + ).toBe(true); + }); + it('does not match a context-overflow 400 or unrelated errors', () => { expect( isToolExchangeAdjacencyError(new APIContextOverflowError(400, 'context length exceeded')), ).toBe(false); expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'Bad request'))).toBe(false); + // A bare "not found" without a tool_call_id anchor must not match, so an + // unrelated 404-style body cannot trip the tool-exchange recovery. + expect(isToolExchangeAdjacencyError(new APIStatusError(400, 'resource not found'))).toBe(false); expect(isToolExchangeAdjacencyError(new APIStatusError(500, ANTHROPIC_MISSING_RESULT))).toBe( false, ); @@ -268,6 +292,12 @@ describe('isRecoverableRequestStructureError', () => { ).toBe(true); }); + it('matches the OpenAI/Moonshot tool_call_id-not-found 400', () => { + expect( + isRecoverableRequestStructureError(new APIStatusError(400, '400 tool_call_id is not found')), + ).toBe(true); + }); + it('matches empty / whitespace-only text content rejections', () => { expect( isRecoverableRequestStructureError(