fix(kosong): recognize OpenAI-compatible tool_call_id 400 as a recoverable tool-exchange error (#1292)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

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.
This commit is contained in:
Kai 2026-07-02 13:52:44 +08:00 committed by GitHub
parent ea55911062
commit 93ec6cb652
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 67 additions and 6 deletions

View file

@ -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.

View file

@ -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'));

View file

@ -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 {

View file

@ -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(