Files
HomeAgent/internal/agent/core/rendertrunc_test.go
JianFeeeee 0ed3c894f5 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* 全绿
2026-11-04 14:15:33 +08:00

157 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// 判据:L1 渲染的**预算截断不得切开 tool_calls 与它的结果**。
//
// ## 守的是一个真实生产故障(2026-11-04)
//
// 生产 A2A 每条消息都回同一句错误:
//
// An assistant message with 'tool_calls' must be followed by tool messages
// responding to each 'tool_call_id'. (insufficient tool messages following
// tool_calls message)
//
// 所有 provider 全部失败,会话彻底不可用。
//
// 排查时先怀疑"历史脏数据"(并发批次漏记 ToolCalls,已另行修复),
// 但**新会话同样报错** —— 说明问题不在历史,在渲染期。
//
// 根因在 renderL1Messages 的预算截断:
//
// for _, m := range msgs {
// if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
// return out // ← 逐条累加,可能切在中间
// }
// out = append(out, m)
// }
//
// `msgs` 是一组 [user?, assistant(tool_calls), tool, tool, ...]。
// 逐条累加时,`return` 可能发生在 assistant 之后、tool 之前
// ⇒ 序列里有一个"永远等不到结果"的 tool_calls ⇒ 上游 400。
//
// 用生产 context.json 的实测数据:
//
// budget=480000 → 469 calls / 386 answered / **83 dangling**
// budget=100000 → 469 calls / 40 answered / 429 dangling
//
// 即默认预算下就已经在切断。
//
// ## 两个约束必须同时满足
//
// ① 组内完整:assistant(tool_calls) 与全部 tool 结果要么都在、要么都不在
// ② 一帧超预算不能吞掉后续:实测见过 468 个 tool_call 的单帧(51 万 token),
// 若直接 return,它之后的**全部历史**一起丢 —— 49 个事件只剩 8 个
import (
"strings"
"testing"
"time"
)
// 下面用真实的 agentAPI.Message,不做抽象 —— 抽象层会让判据测不到真东西。
func TestRenderBudgetNeverSplitsToolPairing(t *testing.T) {
a := New(AgentConfig{ID: "trunc-pairing"})
// 造两帧,每帧 3 个工具调用。
for f := 0; f < 2; f++ {
evt := ContextEvent{
Timestamp: time.Now(), Source: "agent",
Input: "task",
}
for i := 0; i < 3; i++ {
id := "c" + string(rune('a'+f)) + string(rune('0'+i))
evt.ToolCalls = append(evt.ToolCalls, ToolCallItem{
CallID: id, Name: "tool_x",
Arguments: map[string]interface{}{"pad": strings.Repeat("x", 200)},
})
evt.ToolResults = append(evt.ToolResults, ToolResultItem{
CallID: id, Name: "tool_x", Output: strings.Repeat("r", 200),
})
}
a.context.Append(evt)
}
// 扫多个预算档位:任何档位都不得出现悬空调用。
for _, budget := range []int{1, 50, 100, 200, 400, 800, 1600, 100000} {
msgs := a.renderL1Messages(budget)
declared := map[string]bool{}
answered := map[string]bool{}
for _, m := range msgs {
for _, tc := range m.ToolCalls {
declared[tc.ID] = true
}
if m.Role == "tool" {
answered[m.ToolCallID] = true
}
}
for id := range declared {
if !answered[id] {
t.Fatalf("★ budget=%d 时 call %s 无配对结果 ——\n"+
"这正是生产故障:上游会 400 拒绝整个请求。\n"+
"declared=%v answered=%v", budget, id, declared, answered)
}
}
}
}
// TestRenderOversizedFrameDoesNotDropNewerHistory 钉住约束 ②。
//
// 变红条件:把"超大帧跳过并继续"改回"直接 return" ⇒ 红。
func TestRenderOversizedFrameDoesNotDropNewerHistory(t *testing.T) {
a := New(AgentConfig{ID: "trunc-oversize"})
// ① 一个**异常大**的帧(400 个工具调用,远超正常规模)
big := ContextEvent{Timestamp: time.Now(), Source: "agent", Input: "big"}
for i := 0; i < 400; i++ {
id := "big" + strings.Repeat("0", 4) + string(rune('a'+i%26)) + string(rune('0'+i/26%10))
big.ToolCalls = append(big.ToolCalls, ToolCallItem{
CallID: id, Name: "tool_big",
Arguments: map[string]interface{}{"pad": strings.Repeat("x", 400)},
})
big.ToolResults = append(big.ToolResults, ToolResultItem{
CallID: id, Name: "tool_big", Output: strings.Repeat("r", 400),
})
}
a.context.Append(big)
// ② 它之后还有正常对话(更新的历史,绝不能被它吞掉)
after := ContextEvent{
Timestamp: time.Now(), Source: "agent",
Input: "这是超大帧之后的新对话,必须仍然可见",
Response: "ack",
}
a.context.Append(after)
// 预算远小于那个大帧 ⇒ 大帧必须被跳过,但 after 必须留下。
msgs := a.renderL1Messages(5000)
found := false
for _, m := range msgs {
if strings.Contains(m.Content, "超大帧之后的新对话") {
found = true
break
}
}
if !found {
t.Fatalf("★ 超大帧把它之后的新对话一起吞掉了(渲染出 %d 条消息)——\n"+
"实测影响:49 个事件只剩 8 个,上下文几乎清空。\n"+
"正确处置:跳过这一组(组内仍完整),继续渲染更新的帧。", len(msgs))
}
// 且仍然不得有悬空调用(跳过的整组不进 out,所以自然不会悬空)
declared := map[string]bool{}
answered := map[string]bool{}
for _, m := range msgs {
for _, tc := range m.ToolCalls {
declared[tc.ID] = true
}
if m.Role == "tool" {
answered[m.ToolCallID] = true
}
}
for id := range declared {
if !answered[id] {
t.Fatalf("★ 跳过超大帧时产生了悬空调用 %s", id)
}
}
}