mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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
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:
parent
ea55911062
commit
93ec6cb652
4 changed files with 67 additions and 6 deletions
5
.changeset/moonshot-tool-call-id-recovery.md
Normal file
5
.changeset/moonshot-tool-call-id-recovery.md
Normal 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.
|
||||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue