Skip to content

fix(kimi-agent): retry rejected request structure, restore state on undo, count CJK per character - #13

Merged
7723qqq merged 7 commits into
mainfrom
fix/strict-projection-and-audit-ledger
Oct 2, 2026
Merged

7723qqq merged 7 commits into
mainfrom
fix/strict-projection-and-audit-ledger

Conversation

@7723qqq

@7723qqq 7723qqq commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Requirement or Bug

整理一批已完成但未提交的引擎改动,并修正审计台账中指向错误参考树的 provenance 声明。

本 PR 走 PR 路径而非直推 main:仓库 ruleset all 要求 CodeQL 无 error 告警才允许推 main,而 CodeQL 由 push 到 main 触发(codeql.yml:3-6),直推会互为前提地卡住。codeql.yml:8-9 对目标为 main 的 PR 同样触发扫描,因此合并路径可行。

Bug Reproduction Steps

见下面 Root Cause 的三项,每项都对应一个可复现的用户可见现象。

Root Cause

三个独立缺陷,都属于缺失的接线而非设计问题:

  1. provider 拒绝请求结构时整轮失败。 历史里出现重复的 tool_call_id(崩溃后续跑、重试、fork 合并)会被所有 provider 以 400 拒绝,而 turn loop 只把 400 归类为「上下文溢出」或「媒体拒绝」,两者各有能生效的恢复(压缩 / 去媒体),结构问题则无人处理,直接失败告终。缺的是 v2 dedupeDuplicateToolCalls 这一项修复,以及触发它的入口。

  2. POST /undo 只删转录行,不回滚状态。 StateStore::rollback 在生产代码里只有 REPL /undo 一个调用方。REST 路由删掉 transcript 行后,todo.json 与 plan 仍描述着模型已看不到的工作,下一轮继承一份过期计划。台账原引的三处「已有模式」全是假的。

  3. 状态栏 token 估算按字节计。 content.len() / 4 每一字节算 1/4 token,CJK 每字 3 字节 → 低报约 25%。仪表恰好在会话变满时读数偏低。且与压缩触发器用了不同估算器,两者对「有多满」可能给出分歧。

均为根本性修复,非变通方案。

Code Changes

三个功能改动彼此独立:

 run_turn() — provider 报错后的恢复分支
   overflow arm        (压缩)          → 先于新分支
   media arm           (剥离媒体)      → 先于新分支
+  structure arm       (严格投影重发)
     ├─ is_recoverable_request_structure_error(&err)
     │    ├─ status ∉ {400, 422}          → false
     │    ├─ tool_use / tool_result 抱怨   → true
     │    └─ 角色不交替 / 空 text block   → true
     ├─ dedupe_duplicate_tool_calls(&mut messages)
     │    └─ 返回 false(无可修复)→ 不消耗这一次重试
     └─ continue 'overflow_recovery  → 用修好的请求重发

分类顺序是有意的:上下文溢出与媒体拒绝共用 400,它们的恢复确实有效,结构投影换掉只会白费一轮往返。

去重逻辑本身:

 dedupe(messages)
   assistant 分支
     ├─ 重复的 tool_call id          → 丢弃该 call
     ├─ call 全被丢光且无正文无 blocks → 丢弃整条消息(v2 vacuous_message_dropped)
     └─ 存活 → 保留正文,calls 换成过滤后的结果
   tool 分支
     └─ 已回答过的 tool_call_id      → 丢弃这条结果
 // server/mod.rs — undo 路由
   plan_undo_turns()  → 压缩边界拒绝,必须在任何状态变更之前
   undo_turns()       → 删除转录行
+  rollback_state_for_undo(count)
+    └─ checkpoint 是 LIFO 栈 → count=N 必须弹 N 次,不是弹一次
+    └─ 空栈不算错(从未 checkpoint 的会话)
+    └─ 失败如实上报,不静默 —— 行已删除,静默分叉比可见错误更糟
 // server/engine.rs — 状态栏
- history.iter().map(|m| m.content.len() / 4).sum()
+ history.iter().map(|m| compaction::estimate_tokens(&m.content)).sum()
+ // 与压缩触发器共用同一估算器,两者不再对「有多满」产生分歧

另有一处死代码清理:native/event_store/mod.rs 里的 dedupe_duplicate_tool_calls 与 turn_loop/tool_call_id.rs 里的那份重复(前者操作 Message、后者操作 LLMMessage),且无任何生产调用方——只有它自己的测试在调。已删除 253 行。

provenance 订正为纯注释,涉及 6 个文件。核实基准是 upstream/main @ 21406fb4c8:

  • injectCacheControlOnLastBlock(kosong/anthropic.ts:352-362)只处理 messages.at(-1),无 length >= 4 分支 → 上游发 3 个 cache slot,fork 发 4 个,stable 位是 fork 自加
  • CACHEABLE_TYPES 在 :341-350,词汇表本身未受影响,只是出处被写错
  • agent-core-v2/src 全树搜 knowledge / teamTool / lsp / Memory / session_query / run_code / sandbox 只命中散文,无对应文件 → 这七个工具是 fork 自创,不是移植
  • 上游无 anthropic-cache-breakpoints.ts、无 tool/realpath-access.ts、无 workspace/sandbox/

Impact Scope

改动 模块 测试
strict projection llm/request_structure.rs(新)、turn_loop/tool_call_id.rs、turn_loop/run_turn.rs 5 + 5 项单元测试;1 项端到端(a_turn_resends_with_the_strict_projection_after_a_shape_rejection 走真实 turn loop,用一个「只看请求内容决定成败」的 mock —— 也会在去重没真发生时误绿)
undo 回滚 server/mod.rs 2 项:钉 LIFO 到最早锚点;钉空栈不算错
token 估算 server/engine.rs 1 项:ASCII 差异 ≤1(仅进位)、CJK 100 字 = 100 token、混合串按连续 ASCII 段一次进位
provenance 订正 6 文件 无(纯注释)
死代码清理 native/event_store/mod.rs 随删除移除 5 项

本地验证全绿:cargo fmt --check、cargo clippy --all-targets --features cli -- -D warnings、cargo test --no-default-features --features cli,workflow-js(3126 passed / 0 failed)、bun run lint、check:architecture、check:parity、check:roadmap-refs(79 处文件引用 + 147 处测试名全部解析)。

未验证的部分:check:normify 未跑(改动未触及 normify-kimi-code/ 下的模块,但架构指纹变更理论上可能影响它)。真实 provider 的 400 行为是按 v2 的错误分类逐条对照移植的,未在真实 provider 上端到端复现过。

Checklist

  • I have read the CONTRIBUTING document.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.(三条 patch:结构拒绝自动恢复、undo 恢复工作区状态、CJK 仪表修正)
  • Ran gen-docs skill, or this PR needs no doc update.(内部行为修复,已由 changeset 覆盖;docs/ 下无相关用户文档需同步)
  • I have linked a related issue(本仓库内部整理,无外部 issue)

…sitives

Two real defects, both now covered:

- The remote-control page prefix was embedded in an injected `<script>` block
  through `JSON.stringify`, which escapes quotes and backslashes but not `<`. A
  prefix containing `</script>` closed the block early and the rest was parsed as
  markup. Today's only caller percent-encodes both halves, so this was hardening
  rather than a live hole — but the function takes an arbitrary string and the
  escaping it relied on did not make that true.
- A provider error body is parsed for its HTML `<title>` with a lazy span that
  re-scans the tail from every opening tag: measured against the old form, 40k
  opening tags cost over a second, and the body arrives over the network. The
  title is now found by locating the open tag and then the next close tag.

Two smaller changes: the MCP tool-name decoder states its `indexOf` not-found
case instead of folding it into `<= 0` next to a length comparison, and the
private-key redaction rule in the prompt-metadata sanitizer — the one that could
actually leak a credential — finally has a test.

The remaining 30 alerts were triaged rather than code-changed: fourteen
`/\/+$/`-shaped findings are linear (a single-quantifier anchored pattern has
nothing to backtrack into), one `/<[^>]*>/g` likewise, and three are documented
won't-fixes. Each dismissal carries the reason.
…ctor

`&amp;` was decoded first, so the entities decoded after it were re-read: a
double-encoded `&amp;lt;script&amp;gt;` came out of `extractText` as a live
`<script>`. Decoding `&amp;` last makes the pass mean what a single decode of a
document means. Verified directly — the payload stays encoded, and ordinary
named and numeric entities are unaffected.
…r rejects the request's shape

A provider that answers 400/422 about the request's structure — a duplicated
tool_call_id, roles that do not alternate, an empty text block — was ending the
turn as a hard failure. The turn loop now spends at most one strict retry per
turn: it removes the duplicated tool call declarations and the second result
for an already-answered call, then resends.

The verdict is deliberately narrow. Context overflow and media rejection share
the 400 and are classified first, because their recoveries (compaction, media
stripping) actually work and a strict projection would trade one for a wasted
round trip. The retry is also only spent when the dedupe repaired something, so
a shape problem it cannot see does not burn the attempt.
…2 copy

Six provenance notes described the reference as "v2" while actually pointing at
the fork's own retired agent-core-v2 copy, a tree that no longer exists
anywhere. Because those citations all supported "already aligned with v2", the
error was directional rather than noise.

Checked against upstream/main @ 21406fb: injectCacheControlOnLastBlock
(kosong anthropic.ts:352-362) only touches messages.at(-1) with no
length >= 4 branch, so the stable-history breakpoint is fork-added and upstream
emits 3 cache slots where the fork emits 4. A tree-wide search for the tool
names behind Knowledge, Team, lsp, Memory, session_query, run_code and sandbox
returns no files under agent-core-v2/src. No anthropic-cache-breakpoints.ts and
no tool/realpath-access.ts exist upstream either.

Comments only; no behavior changes.
…haracter

POST /undo deleted transcript rows but left todo.json and the plan describing
work the model can no longer see, so the next turn inherited a stale plan. Only
the REPL's /undo ever popped a checkpoint. The route now unwinds the checkpoint
stack after the rows are gone: the compaction-boundary refusal happens first,
and a failure is reported rather than swallowed, since a silent divergence is
worse than a visible error. The stack is LIFO, so count=N pops N times.

The status line estimated context tokens as content.len() / 4, charging a
quarter token per byte and so reading about 25% low on CJK — the gauge drifted
low exactly in the sessions filling up. It now shares compaction's estimator
with the compaction trigger, so the two cannot disagree about how full the
context is.
…fingerprint

The ledger's own reliability was the subject: 58 sampled claims were checked by
opening the cited files rather than confirming the paths exist, which is all the
current gate does. Ten pointed at the retired v2 copy or at paths in neither
tree, and eight of those backed a conclusion about parity with upstream — so the
errors ran in one direction rather than scattering.

The most consequential correction unblocks work: micro compaction's detect()
was marked "no reference implementation, needs a decision", but the reference is
intact and the gap is the two gates plus about 40 lines, not a new subsystem. A
separate sample found 13 more stale citations, including line numbers that now
point at a different function and two provenance reversals.

Also adds MEMORY.md, the environment notes this machine keeps re-learning: SSH
is required because github.com:443 is unreachable here, and bulk file rewrites
through PowerShell convert the whole file to CRLF.

Finally refreshes the kimi-agent fingerprint, which covers the engine sources
touched by the three commits above.
Comment thread apps/kimi-code/test/cli/web/remote-control.test.ts Dismissed
Comment thread packages/node-sdk/src/error-protocol/serialize.ts Dismissed
…ch touched

check:normify flagged seven modules as drifted because the engine sources they
cover changed in this branch. Refreshed through the tool itself rather than by
hand, with each child paired with its parent since refreshing only the child
leaves the parent's aggregate stale.
@7723qqq
7723qqq merged commit ac180b4 into main Oct 2, 2026
17 of 19 checks passed
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.

2 participants