Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moonshot-tool-call-id-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/agent-core/test/loop/tool-exchange-fallback.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] };
}
Expand Down Expand Up @@ -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'));

Expand Down
21 changes: 15 additions & 6 deletions packages/kosong/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions packages/kosong/test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -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(
Expand Down
Loading