MoonshotAI / MoonshotAI/kimi-code

Session permanently bricked by 400 "tool_call_id is not found" after compaction splits a parallel tool-call batch

Open
#1,053 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
7.5k
Forks
1.2k
Avg merge
11h 53m
Merged PRs (30d)
350

Description

Summary

After a context compaction, a session can become permanently unusable: every turn fails with 400 tool_call_id is not found (note the empty id — double space) before the model produces anything. Retrying or rephrasing keeps failing because the same corrupted history is re-sent on each turn; only starting a brand-new session recovers.

ERROR turn failed turnId=103
  APIStatusError: 400 tool_call_id  is not found
    at convertOpenAIError (.../dist-native/intermediates/main.cjs)
WARN  llm request failed turnStep=104.1 model=kimi-for-coding statusCode=400
Environment
  • kimi-code 0.19.1 (native), wire protocol 1.4
  • model: kimi-for-coding
  • macOS (darwin arm64), Node 24
Root cause

The outgoing request contains a role:tool message whose tool_call_id has no matching assistant tool_calls — an orphaned tool result sitting right after the compaction summary.

A full compaction stores a fixed compactedCount split index and later applies it via [summary, ...history.slice(compactedCount)]. When that index falls inside a parallel tool-call batch:

history before compaction:
  idx N    assistant  toolCalls=[A, B]   <- one step, two PARALLEL calls
  idx N+1  tool       result(A)
  idx N+2  tool       result(B)          <- compactedCount points HERE

the retained suffix starts at result(B) while its owning assistant ([A, B]) is folded into the summary, orphaning result(B).

canSplitAfter forbids this split against the materialized array (the next message is a tool), so the count is produced when it is computed against an in-flight batch (only A had landed) and later applied to the fully materialized history — most reliably on resume, where history is rebuilt deterministically with both results present. The request projection does no tool-call/result pairing validation, so the orphan is sent verbatim and the provider rejects every subsequent turn.

Minimal reproduction (no API key needed)

Replay a synthetic record stream — a parallel batch [A, B] plus a context.apply_compaction whose compactedCount lands between result(A) and result(B) — through the real resume path, then inspect the projected request:

const ctx = testAgent({ persistence: new InMemoryAgentRecordPersistence(records) });
await ctx.agent.resume();
const orphans = findOrphanToolMessages(ctx.agent.context.messages);
// orphans === [{ index: 1, id: 'call_B' }]  -> request is rejected with 400
Suggested fix

Drop orphaned leading tool results when applying a compaction: a tool result can never legitimately begin the retained suffix. (A PR with this fix + a regression test is on the way.)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the context.apply_compaction handling and the real agent resume path, then reproduce the synthetic record stream using testAgent and InMemoryAgentRecordPersistence. Inspect how compactedCount is applied to the materialized message history and use findOrphanToolMessages to verify the projected request. Done means the resumed history contains no orphaned tool results and the regression case no longer produces the 400 response.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.