fix(agent): L1 预算截断切开 tool 配对 —— 这才是上游 400 的真因

## 上一个提交修的是另一半,这是主因

263b651 修了"并发批次漏记 ToolCalls"(历史脏数据)。但**新会话仍然报同样的错**,
说明还有一条独立的路径在制造非法序列。

## 定位过程

① 先读内核自己写的日志(homed_<时间>.log):

    overflow check #1: ratio=1.21 events=41 (threshold=1.25)

上下文**没触发裁剪**(1.21 < 1.25)——所以不是裁剪切断配对。

② 于是直接拿生产 context.json 的真实数据跑渲染,按 call_id 统计配对:

    budget=480000 → 469 calls / 386 answered / **83 dangling**
    budget=100000 → 469 calls /  40 answered / 429 dangling

**默认预算下就已经在切断。**

## 根因

renderL1Messages 的预算截断是**逐条消息**累加的:

    for _, m := range msgs {
        if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
            return out          // ← 可能切在 assistant(tool_calls) 之后、tool 之前
        }
        out = append(out, m)
    }

`msgs` 是一组 [user?, assistant(tool_calls), tool, tool, ...]。`return` 落在
组中间 ⇒ 序列里留下一个永远等不到结果的 tool_calls ⇒ 上游 400。

这与 InactivePages 的约束是同一件事(那边注释已写着"剔掉 call 却留下 result
就是造一个必崩的帧")—— **整组要么全留,要么全丢**。

## 修法:以事件(消息组)为截断单位 + 跳过失控帧

    ① 组内完整:groupCost 一次算完,整组 append 或整组不 append
    ② 单组超过预算 1/4 视为**失控帧**(实测见过 468 个 tool_call 的单帧,
       51 万 token),此时跳过它继续往后渲染 —— 否则它会把之后的全部
       历史一起吞掉(实测 49 个事件只剩 8 个,上下文几乎清空)

② 的判据只看"这一组自己是否失控",**不看 out 是否为空**:
第一版写成 `len(out)==0 || groupCost*4 <= maxTokens`,于是失控帧恰好
排在第一位时仍然 return —— 判据实测抓到"渲染出 0 条消息"。

## 判据(均经变异验证)

  TestRenderBudgetNeverSplitsToolPairing
      扫 8 个预算档位,任何档位都不得有悬空调用
      变异:改回逐条累加 ⇒ budget=50 时 "call ca0 无配对结果"(实测红)

  TestRenderOversizedFrameDoesNotDropNewerHistory
      失控帧之后的新对话必须仍然可见,且跳过时不产生悬空
      变异:去掉"跳过"分支 ⇒ "渲染出 0 条消息"(实测红)

## 用生产真实数据验证修复效果

    修复前:budget=358553 → 14 条消息(5 assistant / 1 tool / 8 user)
    修复后:budget=358553 → 236 条消息(41 assistant / 147 tool / 48 user)
             dangling=0,四个预算档位均为 0

即:不再有非法序列,且上下文不再被单帧吞空。

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + TestRender* 全绿
This commit is contained in:
JianFeeeee
2026-11-04 14:15:33 +08:00
parent 263b651bf0
commit 0ed3c894f5
2 changed files with 212 additions and 5 deletions

View File

@ -685,19 +685,70 @@ func (a *Agent) renderL1Messages(maxTokens int) []agentAPI.Message {
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tr.CallID, Content: tr.Output})
}
}
// ★ 预算截断以**事件(消息组)**为最小单位,绝不切开一组的内部
//(2026-11-04 生产故障修复)。
//
// 此前是逐条消息累加,超预算就 return —— 可能正好切在
// assistant(tool_calls) **之后**、它的 tool 结果**之前**。
// 这样的序列上游直接 400:
//
// An assistant message with 'tool_calls' must be followed by
// tool messages responding to each 'tool_call_id'
//
// ⇒ 所有 provider 全部失败 ⇒ 会话彻底不可用。
//
// 实测(用生产 context.json 真实数据跑渲染):
//
// budget=480000 → 469 calls / 386 answered / **83 dangling**
// budget=100000 → 469 calls / 40 answered / 429 dangling
//
// 即**默认预算下就已经在切断**。这也解释了为什么新会话也报错:
// 不是历史脏数据,是渲染期把当前上下文切坏了。
//
// 这与 InactivePages 的约束是同一件事(那里注释写着"剔掉 call
// 却留下 result 就是造一个必崩的帧")——整组要么全留,要么全丢。
groupCost := 0
for _, m := range msgs {
cost := EstimateTokens(m.Content)
groupCost += EstimateTokens(m.Content)
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
cost += EstimateTokens(tc.Name) + EstimateTokens(tc.RawArguments)
groupCost += EstimateTokens(tc.Name) + EstimateTokens(tc.RawArguments)
}
}
if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
}
if maxTokens > 0 && used+groupCost > maxTokens {
// 整组放不下。
//
// ★ 两种处置,取决于"这个组是不是异常大":
//
// ① 正常组(> 剩余预算,但本身不失控):**停止**。
// 再往前就是更旧的对话,继续追加只会更超。
// 这也是原来 `return out` 的语义。
//
// ② **单组就超过总预算的 1/4**:这一组异常(实测见过
// 468 个 tool_call 的单帧,占 51 万 token),它放不下
// 不是因为"前面装了太多",而是它自己失控。
// 此时 `return` 会把**它之后的全部历史**一起丢掉 ——
// 实测 49 个事件只剩 8 个,上下文几乎清空。
// 所以跳过这一组、继续往后渲染(更新的对话更重要)。
//
// 两种处置都保证**组内完整性**(要么全有要么全无),
// 因此都不会产生"有 tool_calls 无配对结果"的非法序列。
// ★ 判据只看"这一组自己是否失控",不看 out 是否为空。
//
// 第一版写成 `len(out)==0 || groupCost*4 <= maxTokens`,
// 于是超大帧恰好排在**第一位**时(out 为空)仍然 return ——
// 判据实测抓到:渲染出 0 条消息,之后的新对话全部消失。
if groupCost*4 <= maxTokens {
return out
}
used += cost
out = append(out, m)
log.Printf("[agent] skip oversized frame (cost=%d > budget/4=%d) — "+
"keeping %d earlier messages, continuing with newer frames",
groupCost, maxTokens/4, len(out))
continue
}
used += groupCost
out = append(out, msgs...)
}
return out
}