Skip to content

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

Description

@tomsen02

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions