Visitar URL original
fix(agent-core-v2): strip media and retry once on unclassified client 400s by clariszyf · Pull Request #4864 · MoonshotAI/kimi-code · GitHub
Skip to content

fix(agent-core-v2): strip media and retry once on unclassified client 400s - #4864

Open
clariszyf wants to merge 4 commits into
MoonshotAI:mainfrom
clariszyf:fix/media-strip-unclassified-400
Open

clariszyf wants to merge 4 commits into
MoonshotAI:mainfrom
clariszyf:fix/media-strip-unclassified-400

Conversation

@clariszyf

@clariszyf clariszyf commented Nov 12, 2026 •

Copy link
Copy Markdown

Requirement or Bug

Resolve #4863

一次正常的读图操作(ReadMediaFile 读取一张很小的 PNG)之后,服务端返回了通用 400 Invalid request Error。由于错误未被识别为与媒体相关,会话不做任何恢复:该步直接失败,之后的重试、后续 turn、甚至手动触发的 full compaction 都把同一条图片消息原样重发、以同样的 400 失败,整个会话不可恢复直到崩溃。本 PR 让这类"携带媒体的请求被未分类客户端 400 拒绝"的场景可以自动剥离媒体重试一次,会话得以继续。

注:按 CONTRIBUTING 约定,本 PR 链接的 issue 尚待维护者 /approve 评论批准。

Bug Reproduction Steps

See linked issue(issue 中包含 debug 包时间线、三次 400 的 requestId 与 wire.jsonl 证据)。

Root Cause

这是根因修复(针对客户端恢复缺口;服务端为何返回通用 400 属于网关行为,客户端无法依赖其消息内容)。

时序如下:

  1. ReadMediaFile 工具结果以 kimi-file:// 图片消息进入上下文;请求时 AgentMediaResolverService.resolve 将其解析为 data URL / 上传引用,这一步正常。
  2. 网关返回通用 400 Invalid request Error。toLlmStatusErrorMessage(src/human/llm/protocol/rules/status.ts)中它不匹配 image_format、request_structure 等任何消息模式,被归为 kind: 'status'。
  3. 媒体恢复策略 createMediaDegradeRecovery(src/human/llm/builtin/policy/media-degrade.ts)只对 request_too_large 和 image_format(含 unsupported media type for base64 image / invalid data url for image 消息模式)提出剥离/降级,通用 400 不在范围内,于是当步失败、无剥离重试。
  4. fix(agent-core-v2): keep media recovery across turns and compaction #4413 让恢复快照跨 turn 与 compaction 持续,但前提是先产生过恢复记录;本次一次都没有,后续 turn 与 full_compaction(droppedCount=0)把同一媒体原样重发 → 同样 400 → 会话死亡。

Code Changes

在恢复策略中新增一个分支:未分类的客户端 400(kind: 'status'、statusCode === 400、非 retryable)且当前请求携带媒体部件时,走与 image-format 拒绝相同的剥离重试路径,但标记为专用 action stripped-client-error。

 propose({ error, request, appliedRecoveries })
   imageFormat = ...(不变)
+  mediaClientError = error.kind === 'status'
+    && error.statusCode === 400
+    && !error.retryable
+    && countMediaParts(request.content.messages) > 0
-  if (error.kind !== 'request_too_large' && !imageFormat) return undefined
+  if (error.kind !== 'request_too_large' && !imageFormat && !mediaClientError) return undefined
   ...
-  return stripEntry(messages, appliedRecoveries, replacement)
+  strip = stripEntry(...)
+  if (strip !== undefined && mediaClientError)
+    return { ...strip, action: 'stripped-client-error' }   // 不持久化
+  return strip

同时两处配套:

  • core/request.ts 的恢复存储在写入时按 action 过滤:stripped-client-error 永远不会被持久化到跨请求恢复存储(在 store 边界过滤,而不是只在事件处跳过——否则后续任意 media-degrade 事件都会把整个 ledger 连同它一起写入)。
  • 剥离记账 isStrippedRecord 同时识别 stripped 与 stripped-client-error,避免混合错误序列(通用 400 剥离后重试又遇 413)产生无意义的无重写恢复事件。

因果说明:

  • 为什么不持久化通用 400 的剥离:通用 400 不一定由媒体引起。若误剥离被持久化,无关故障恢复后,后续请求会在没有警告的情况下永久丢失有效图片。不持久化后,误判的最大代价是当次请求多一次必然同样失败的重试,错误照常上浮;任何后续请求都不受影响。这是从设计上消除误伤,而不是逐场景加守卫。
  • 代价:原始崩溃场景中,只要图片还在历史里,后续每个 step 的首次请求仍会得到一次通用 400,再剥离重试成功(每次多一个失败请求和一条 warning)。若维护者希望成功后持久化(省掉重复 400),需要"重试成功才持久化"的 tentative 机制,复杂度高得多,本 PR 刻意不做。
  • 为什么排除 retryable 错误:可重试的 400 应保留媒体走正常 backoff,剥离会让后续 backoff 请求永久丢失图片。
  • 为什么不会循环:剥离后媒体部件消失,countMediaParts === 0 不再命中新分支,stripEntry 也因无媒体且已剥离返回 undefined,错误照常上浮。
  • 既有行为:413 / image-format 的剥离/降级及其跨 turn/compaction 持久化(fix(agent-core-v2): keep media recovery across turns and compaction #4413)完全不变;既有测试 does not resend for an unrelated 400(无媒体)语义不变。

Behavior Changes and Affected Users

Behavior Before After Who relies on the old behavior Escape hatch
请求携带媒体部件时收到未分类的客户端 400(如通用的 Invalid request Error) 当步失败;后续 turn / compaction 原样重发同一媒体,全部同样 400,会话不可恢复 当次请求剥离全部媒体重试一次;成功则继续,仍失败则原错误上浮;剥离结果不持久化,后续请求照常携带媒体 无(此前该场景 100% 失败,无依赖方) none
请求不携带媒体时收到客户端 400 不重试,直接失败 不变 依赖确定性失败以便排查请求本身问题的用户 N/A
被标为 retryable 的客户端 400 现有重试策略决定是否 backoff 不变,且不会剥离媒体 依赖 backoff 重试保留原始请求的 provider / 自定义 adapter N/A
413 / image-format 拒绝的恢复路径及跨请求持久化 既有 degrade/strip + #4413 持久化行为 不变 全部既有用户 N/A
通用 400 剥离后又遇 413 等混合错误序列 该序列此前不存在(通用 400 无恢复) 剥离记账识别两种 strip action,无 no-op 恢复事件,错误正常上浮 无 N/A

受影响模块与测试覆盖:

  • src/human/llm/builtin/policy/media-degrade.ts(新分支与剥离记账)、src/agent/loop/core/request.ts(存储边界 action 过滤)— 由 test/agent/llmRequester/llmRequesterService.test.ts 新增七个用例覆盖:
    • 通用 400 + 携带媒体 → 剥离后重试并成功(calls.value === 2,媒体数 [1, 0]);
    • 通用 400 + 无媒体 → 不重试(钉住旧行为);
    • 剥离后重试仍失败 → 原错误上浮且仅重试一次;
    • retryable 的 400 + 携带媒体 → 不剥离,媒体原样保留;
    • 剥离成功后的后续请求 → 不继承剥离,媒体照常发送([1, 0, 1]);
    • 剥离重试失败后的后续请求 → 同样不继承([1, 0, 1]);
    • 通用 400 剥离后重试遇 413(混合错误序列)→ 不持久化、无 no-op 恢复事件,后续请求媒体仍在。
    • 已验证:回退源码改动后新用例如期失败(测试非空转)。
  • 验证范围:llmRequesterService.test.ts 75/75 通过;modelRequester.test.ts 14/14 通过;包级 typecheck 通过。sessionMediaStore.test.ts 与 fullCompaction.test.ts 各有 4/1 个失败,已在干净基线上复现确认为 Windows 环境既有失败,与本改动无关。
  • 另经独立代码审查一轮:发现的"后续 media-degrade 事件会把整个 ledger 连同通用剥离一起持久化"的漏洞已通过存储边界过滤 + 剥离记账修复并回归测试覆盖,复查无遗留问题。

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue (external PRs: issue must have a maintainer's /approve).
  • I have added tests that prove my feature works.
  • The behavior-change table above is complete, and every removed behavior or flipped default is named in the changeset and either has an escape hatch or was explicitly approved by a maintainer in this PR.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update.

@changeset-bot

changeset-bot Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 23e122b

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

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code 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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T06:31:29.380030Z 23e122b New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 00c5591d81

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +203 to +206
const mediaClientError =
error.kind === 'status' &&
error.statusCode === 400 &&
countMediaParts(request.content.messages) > 0;

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 Let retryable 400s use normal backoff before stripping

When a provider or custom adapter marks a generic HTTP 400 as retryable: true and the request contains media, this recovery runs before runAttempts reaches shouldRetry, records a strip, and makes every later backoff attempt omit valid media; if the transient retry then succeeds, the model answers without the image even though the provider explicitly requested a retry. The status contract preserves retryability independently of the HTTP code, so exclude retryable errors here or let normal retries run first.

AGENTS.md reference: AGENTS.md:L110-L112

Useful? React with 👍 / 👎.

const mediaClientError =
error.kind === 'status' &&
error.statusCode === 400 &&
countMediaParts(request.content.messages) > 0;

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 Check the media that actually reached the provider

When AgentMediaResolverService converts a daemon or inline media part to text before sending it—for an unsupported model, an unreadable or invalid file, or the media budget—runAttempts still passes this policy the pre-agent-media recovery request, so this count is nonzero although the wire request contained no media. An unrelated 400 therefore records and persists a media-strip rewrite and can change the next request's text without emitting the media warning; base this gate on the resolved attempt that actually failed.

AGENTS.md reference: AGENTS.md:L130-L132

Useful? React with 👍 / 👎.

@clariszyf

Copy link
Copy Markdown
Author

已处理 Codex 自动审查的两条 P2 意见(commit 484a988):

  1. retryable 400 不应剥离媒体:新分支现在排除 retryable 错误,可重试的 400 保留媒体走正常 backoff。新增测试覆盖。
  2. 应按实际到达提供方的媒体判定:恢复策略改由 createAgentMediaPolicy 在每次 service.resolve 后记录解析结果是否仍含媒体(闭包谓词传入),resolver 已降级为文本时无关 400 不再触发剥离重写。新增测试覆盖。

验证:llmRequesterService.test.ts 73/73、modelRequester.test.ts 14/14、包级 typecheck 通过。

@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: 484a988942

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

? new Map()
: await service.displayPaths(current.content.messages);
const messages = await service.resolve(current.content.messages, model, request, control.signal);
resolvedHasMedia = countMediaParts(messages) > 0;

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 Measure media after protocol lowering

When an Anthropic request contains audio_url, or an OpenAI Responses request contains video_url, this flag is true even though encoding later converts those parts to text (packages/agent-core-v2/src/human/llm/builtin/protocol/anthropic/legalize.ts:17-22 and packages/agent-core-v2/src/human/llm/builtin/protocol/openai-responses/lower.ts:87-94). If that media-free wire request gets an unrelated non-retryable 400, the new generic branch still records and persists a media strip before retrying. Fresh evidence after the earlier review is that the new resolver-output check runs before protocol legalization/lowering; measure the encoded request, or only parts the selected protocol will actually transmit, instead.

AGENTS.md reference: AGENTS.md:L110-L112

Useful? React with 👍 / 👎.

@clariszyf

Copy link
Copy Markdown
Author

已处理第二轮 Codex 审查的 P2 意见(commit f8c5963):

媒体判定收窄到图片:协议编码层会把 Anthropic 的 audio_url、OpenAI Responses 的 video_url 降级为文本,因此新分支不再把这些部件视为"媒体到达 wire"的证据——闭包谓词只检查解析结果是否仍含 image_url。原始崩溃场景(ReadMediaFile)只产图片,修复目标不变。

新增测试 does not strip for requests whose only media are parts protocols may lower to text 覆盖;llmRequesterService.test.ts 74/74、包级 typecheck 通过。

@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: f8c59631b0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +206 to +210
const mediaClientError =
error.kind === 'status' &&
error.statusCode === 400 &&
!error.retryable &&
countMediaParts(request.content.messages) > 0 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist a generic strip only after its retry succeeds

When an image-bearing request hits an unrelated generic 400 and the stripped retry also fails, accepting this proposal has already made AgentLoopRequestFactory.markMediaRecovery store the stripped ledger, and the failed outcome never rolls it back. createMediaRecoveryStore.read reapplies that ledger to later steps or turns for the same model and history revision, so once the unrelated failure clears a valid image is silently omitted without another warning. Keep this recovery tentative and persist it only after the media-free retry succeeds.

AGENTS.md reference: AGENTS.md:L130-L132

Useful? React with 👍 / 👎.

@clariszyf

Copy link
Copy Markdown
Author

已处理第三轮 Codex 审查的 P1 意见,并简化了整体设计(commit 23e122b):

问题:通用 400 的误剥离会被 markMediaRecovery 持久化到跨请求恢复存储,无关故障恢复后,后续请求会在无警告的情况下丢失有效图片。

最终方案:通用 400 的剥离只在当次请求内生效、永不持久化——在 createMediaRecoveryStore.write 的 store 边界按 action(stripped-client-error)过滤。这样从设计上消除了误伤面,此前为防误伤加的闭包谓词、tentative 持久化机制等守卫全部撤掉,最终只改 2 个源码文件。

  • 代价:原始崩溃场景中每个 step 首次请求仍会多一次通用 400 + 剥离重试;若维护者希望成功后持久化,需要 tentative 机制,本 PR 刻意不做。
  • 另修复了独立审查发现的泄漏路径:后续 media-degrade 事件会把整个 ledger 连同通用剥离一起写入存储——store 边界过滤后该路径关闭;剥离记账 isStrippedRecord 同时识别两种 strip action,消除混合错误序列的 no-op 恢复事件。
  • 新增回归测试覆盖 generic 400 → 413 混合序列;llmRequesterService.test.ts 75/75、modelRequester.test.ts 14/14、包级 typecheck 通过。独立审查复查无遗留问题。

This branch has not been deployed

No deployments
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.

ReadMediaFile 的图片消息触发 400 后永久毒化上下文:重试与 full compaction 均无法恢复,会话崩溃

1 participant