From 0ed3c894f5ef07f860fae3b405467d28d09f9367 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 4 Nov 2026 14:15:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20L1=20=E9=A2=84=E7=AE=97=E6=88=AA?= =?UTF-8?q?=E6=96=AD=E5=88=87=E5=BC=80=20tool=20=E9=85=8D=E5=AF=B9=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=BF=99=E6=89=8D=E6=98=AF=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20400=20=E7=9A=84=E7=9C=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 上一个提交修的是另一半,这是主因 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* 全绿 --- internal/agent/core/process.go | 61 ++++++++- internal/agent/core/rendertrunc_test.go | 156 ++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 internal/agent/core/rendertrunc_test.go diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 9b25e2f7..e9a153b9 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -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 } diff --git a/internal/agent/core/rendertrunc_test.go b/internal/agent/core/rendertrunc_test.go new file mode 100644 index 00000000..e150d628 --- /dev/null +++ b/internal/agent/core/rendertrunc_test.go @@ -0,0 +1,156 @@ +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) + } + } +}