Skip to content

fix(agent-core): recover sessions bricked by orphan tool results - #1308

Merged
RealKai42 merged 2 commits into
mainfrom
fix/tool-exchange-400-recovery
Jul 2, 2026
Merged

fix(agent-core): recover sessions bricked by orphan tool results#1308
RealKai42 merged 2 commits into
mainfrom
fix/tool-exchange-400-recovery

Conversation

@RealKai42

Copy link
Copy Markdown
Collaborator

背景

用户在 OpenAI 兼容 provider(DeepSeek / MiMo 网关)上会话被永久锁死:每个 turn 都以 400 Messages with role 'tool' must be a response to a preceding message with 'tool_calls' 失败,切模型、切 provider 都无效

根因是两个独立缺陷叠加——一个"制造损坏",一个"让损坏无法自愈":

1. 错误措辞未被识别(kosong)

代码本有 post-400 strict-resend 自救(重投影时丢弃孤儿 tool 结果),但它只在错误被识别为结构性错误时触发。识别正则此前只覆盖 Anthropic 和 Moonshot/Kimi 的措辞,没覆盖 OpenAI / DeepSeek / vLLM / Qwen 的措辞,于是自救从不触发。

本 PR 新增两个镜像方向的识别(quote 兼容直引号/反引号/双引号):

  • 方向 A(孤儿 tool 结果):role 'tool' must be a response to a preceding message with 'tool_calls'
  • 方向 B(assistant tool_calls 缺结果):assistant message with 'tool_calls' must be followed by tool messages / tool_call_ids did not have response messages / (insufficient tool messages following ...)

两个方向 strict-resend 都能修(丢孤儿 / 合成缺失)。并加了 Not supported model 负例,确保这类配置错误不会被误当结构错误触发自救。

2. legacy-restore 焊入孤儿(agent-core)

applyCompaction 的 legacy-restore 分支(恢复旧版本压缩过的会话)保留逐字尾部 history.slice(compactedCount)。当切点落在一次 tool 交换中间时,尾部会以孤儿 tool 结果开头(其 assistant 已被摘要吃掉),而普通投影不修复 leading 孤儿,于是这条孤儿被永久写进历史、每个 turn 重发。

本 PR 在该分支用 dropLeadingToolResults 裁掉尾部开头的孤儿。因原始历史良构且连续,只有尾部开头一段可能孤儿,后续 tool 结果的 assistant 一定还在尾部,所以只丢 leading 一段是充分且安全的。

测试

  • 遵循先写失败测试→修复→转绿:
    • kosong/test/errors.test.ts:方向 A/B 措辞识别 + Not supported model 负例
    • agent-core/.../tool-exchange-fallback.e2e.test.ts:OpenAI 措辞端到端 strict-resend 自救
    • agent-core/test/agent/resume.test.ts:legacy-restore 切在 tool 交换中间不得留孤儿(改前实证复现)
  • 全量:kosong + agent-core 4544 passed;tsc --noEmit 两包通过;oxlint --type-aware 0 warning 0 error

备注

日志里的 Not supported model mimo-v2.5-pro-ultraspeed 是独立的配置问题(切到了 endpoint 不支持的模型别名),不在本 PR 范围。

A stray `tool` message with no preceding assistant `tool_calls` permanently
bricked a session on OpenAI-compatible providers: every turn re-sent the same
malformed history and got a 400, and switching model/provider did not help.

Two independent gaps caused this:

- kosong did not recognize the OpenAI / DeepSeek / vLLM / Qwen phrasings of the
  tool-exchange structural 400 (`role 'tool' must be a response to a preceding
  message with 'tool_calls'` and the mirror `assistant message with 'tool_calls'
  must be followed by tool messages`), so the post-400 strict-resend fallback
  that drops the orphan never fired.

- The legacy-restore compaction path kept a verbatim tail
  `history.slice(compactedCount)`; when the cut landed inside a tool exchange the
  tail began with an orphan tool result whose assistant was summarized away. The
  normal projection does not repair a leading orphan, so the malformed history
  was baked in and re-sent every turn.

Recognize the additional phrasings so the strict resend un-bricks any session,
and trim leading tool results from the legacy-restore tail so the orphan is
never persisted in the first place.
@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 338818d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@moonshot-ai/kosong Patch
@moonshot-ai/agent-core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@338818d
npx https://pkg.pr.new/@moonshot-ai/kimi-code@338818d

commit: 338818d

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4527d9d69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

input.compactedCount < this._history.length;
this._history = isLegacyRestore
? [summaryMessage, ...this._history.slice(input.compactedCount)]
? [summaryMessage, ...dropLeadingToolResults(this._history.slice(input.compactedCount))]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep transcript folding in sync with dropped tool results

When this legacy-restore path drops leading tool results, the live context.history becomes shorter than the old [summary, ...tail] shape, but readWireTranscript still mirrors the legacy branch as foldedLength = 1 + (foldedLength - record.compactedCount) in packages/agent-core/src/services/message/transcript.ts:274-282. For an old session whose compaction cut starts with one orphan tool result, MessageService will overestimate foldedLength by one; while the resumed session has unflushed live messages, _getTranscriptEntries slices context.history from that too-large index and omits the first live-tail message from /messages until the wire log catches up. Please update the transcript reducer to subtract the same leading dropped tool run.

Useful? React with 👍 / 👎.

Rework the legacy-restore half of the fix based on review feedback: mutating
`_history` at restore time desyncs every consumer that models the history from
the wire records — the transcript reducer's fold length would overcount and
make MessageService skip unflushed live-tail messages.

Keep the restored history faithful to the wire records instead, and drop a
`tool` result whose call is nowhere in the history at the projection boundary,
on every request-building projection: the normal wire (`messages`), the
post-400 strict resend (`strictMessages`), and the compaction summarizer. An
orphan is wire-invalid on strict providers and useless to the model either
way, so it never reaches a provider — no longer relying on recognizing the
provider's 400 phrasing to recover. Fragment projections (e.g. token-estimating
a history slice) leave results untouched, since a matching call may
legitimately sit outside the slice.
@RealKai42
RealKai42 merged commit 4dd926b into main Jul 2, 2026
9 checks passed
@RealKai42
RealKai42 deleted the fix/tool-exchange-400-recovery branch July 2, 2026 11:29
@github-actions github-actions Bot mentioned this pull request Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant