fix(agent-core): recover sessions bricked by orphan tool results - #1308
Conversation
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 detectedLatest commit: 338818d The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
commit: |
There was a problem hiding this comment.
💡 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))] |
There was a problem hiding this comment.
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.
背景
用户在 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 兼容直引号/反引号/双引号):
role 'tool' must be a response to a preceding message with '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-core4544 passed;tsc --noEmit两包通过;oxlint --type-aware0 warning 0 error备注
日志里的
Not supported model mimo-v2.5-pro-ultraspeed是独立的配置问题(切到了 endpoint 不支持的模型别名),不在本 PR 范围。