fix(agent): 发送前自检 tool_call 配对 —— 止损并定位上游 400

## 为什么还要修(前面两个修复都不够)

今天已经修了两处:

  263b651  并发批次 stepToolBatch 漏记 ToolCalls
  0ed3c89  L1 预算截断切开 tool 配对

并用生产 context.json 验证历史已干净:

    53 个事件,0 个"结果多于调用"的帧

但 A2A **新会话**(a2a_1793778753015670668)仍然报同一个 400:

    An assistant message with 'tool_calls' must be followed by tool messages
    responding to each 'tool_call_id'. (insufficient tool messages following...)

同时日志显示 `ratio=0.28`(远低于 1.25 阈值)、`skip oversized frame` 计数 0
⇒ 既不是裁剪,也不是预算截断。

## 定位:问题在 f.Msgs,不在 L1 历史

`f.Msgs` 是**多条路径**拼出来的:

    prepare 重建      buildMessages
    中断恢复          rebaseFramePrefix(重建 prefix + 接回 tail)
    批末附加          flushBatchAttachments
    占位补全          dropContinuationPlaceholders / continuation

任何一条漏掉配对,最终都表现为同一个 400,而协议**不告诉你是哪条 call**。
所以把自检放在唯一一次组装 provider 请求的位置(stepLLM,task.go:750),
而不是散在各条路径上。

## 为什么剔 call 而不是补占位

补一条 "tool result: (missing)" 会让模型看到它**从未调用过**的结果,
可能据此继续推理 —— 那是编造事实。剔掉 call 只是少了一次尝试,
模型可以重来,语义安全。

被剔的 call 记录工具名与数量,日志里能看出是哪一轮出的问题。

## 判据(5 条,3 次变异全部实测变红)

  DropsCallWithoutResult       主方向:没有结果的 call 必须剔
      变异:直接 return msgs ⇒ "★ 未配对的 call c2 仍在消息里 —— 上游必然 400"

  DropsOrphanToolResult        反方向:找不到 call 的孤立 tool 必须删
      变异:删掉第二遍循环 ⇒ 红

  DropsEmptyAssistantShell     call 全剔且正文为空 ⇒ 整条删
      变异:去掉该分支 ⇒ 红

  KeepsContentWhenAllCallsDropped
      call 全剔但**有正文**时保留正文(那是模型说的话,不能丢)

  LeavesValidSequenceUntouched
      合法序列必须逐条不变 —— 修正逻辑不能引入新问题

只测一个方向会放过另一半失配,所以两个方向都钉。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestDropUnpaired 全绿

## 下一步

这条是**止损 + 定位**,不是根因修复。部署后如果日志里出现
"dropped N unpaired tool message(s)",那 N 与工具名就指向真正漏配对的
那条路径,据此再修上游。
This commit is contained in:
JianFeeeee
2026-11-04 15:58:22 +08:00
parent af17af5082
commit 4ab888ce06
3 changed files with 370 additions and 0 deletions

View File

@ -0,0 +1,171 @@
package core
// 发送前的消息配对自检(2026-11-04)。
//
// ## 要解决的是什么
//
// 上游(OpenAI 兼容协议)对这个形态是**硬拒绝**:
//
// 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 全部失败 —— 用户看到的是一句英文报错,
// 而不是任何业务回复。且**没有定位信息**:协议不告诉你是哪条 call。
//
// ## 为什么光是修渲染层不够
//
// 2026-11-04 修了两处(并发批次漏记 ToolCalls、L1 预算截断切开配对),
// 用生产 context.json 验证历史已干净(0 个 result 多于 call 的帧)。
// 但 A2A 新会话**仍然报同一个 400**。
//
// 原因:`f.Msgs` 是**多条路径**拼出来的 ——
//
// prepare 重建 buildMessages
// 中断恢复 rebaseFramePrefix(重建 prefix + 接回 tail)
// 批末附加 flushBatchAttachments
// 占位补全 dropContinuationPlaceholders / continuation
//
// 任何一条漏掉配对,最终都表现为同一个 400。所以把自检放在**唯一一次
// 组装 provider 请求的位置**(stepLLM),而不是散在各条路径上。
//
// ## 为什么剔 call 而不是补占位
//
// 补一条 "tool result: (missing)" 会让模型看到它从未调用过的结果,
// 可能据此继续推理 —— 那是**编造事实**。剔掉 call 只是少了一次尝试,
// 模型可以重来,语义上是安全的。
//
// 被剔的 call 会记录到日志(工具名 + ID),用于定位是哪条路径产生的。
import (
"log"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
// dropUnpairedToolCalls 剔掉没有配对结果的 tool_calls。
//
// 规则(与协议严格对齐):
// - 一条 assistant 消息声明的每个 call,必须在**紧随其后的连续 tool 消息**
// 里各有一条同 ID 的结果;
// - 缺失的 call 从该 assistant 消息里删除;若删空且正文也为空,整条消息删除;
// - 对应的孤立 tool 消息(找不到 call 的)一并删除。
//
// 只在**真的有问题**时才改动切片,正常路径零开销(一次线性扫描)。
func dropUnpairedToolCalls(msgs []agentAPI.Message) []agentAPI.Message {
if len(msgs) == 0 {
return msgs
}
// 先扫一遍判断有没有问题:绝大多数请求是干净的,不做任何分配。
if !hasUnpairedToolCalls(msgs) {
return msgs
}
out := make([]agentAPI.Message, 0, len(msgs))
dropped := 0
var droppedNames []string
for i := 0; i < len(msgs); i++ {
m := msgs[i]
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
// 非 call 声明者:原样保留(孤立的 tool 消息在下面单独处理)。
out = append(out, m)
continue
}
// 收集紧随其后的连续 tool 消息里出现的 call ID。
answered := make(map[string]bool, len(m.ToolCalls))
for j := i + 1; j < len(msgs); j++ {
if msgs[j].Role != "tool" {
break // 协议要求 tool 消息紧接在 assistant 之后
}
if id := msgs[j].ToolCallID; id != "" {
answered[id] = true
}
}
kept := make([]agentAPI.ToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
if tc.ID == "" || answered[tc.ID] {
kept = append(kept, tc)
continue
}
dropped++
if len(droppedNames) < 8 { // 日志别刷屏
droppedNames = append(droppedNames, tc.Name)
}
}
if len(kept) == len(m.ToolCalls) {
out = append(out, m)
continue
}
// 有 call 被剔:若正文也没了,整条 assistant 消息一起删。
if len(kept) == 0 && m.Content == "" {
continue
}
m.ToolCalls = kept
out = append(out, m)
}
// 第二遍:删掉找不到 call 的孤立 tool 消息。
declared := make(map[string]bool)
for _, m := range out {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
declared[tc.ID] = true
}
}
}
final := out[:0]
for _, m := range out {
if m.Role == "tool" && m.ToolCallID != "" && !declared[m.ToolCallID] {
dropped++
continue
}
final = append(final, m)
}
log.Printf("[agent] ★ dropped %d unpaired tool message(s) before send "+
"(names: %v) — 上游会以 400 拒绝这类序列,剔掉后请求可继续",
dropped, droppedNames)
return final
}
// hasUnpairedToolCalls 是 dropUnpairedToolCalls 的**只读探测**版本。
//
// 单独写一份的理由:正常路径(绝大多数请求)应当零分配返回,
// 而 dropUnpairedToolCalls 需要建 map/slice。两份逻辑必须同源,
// 所以这里只判"是否合法",不为修正做准备。
func hasUnpairedToolCalls(msgs []agentAPI.Message) bool {
// 先收集所有声明过的 call ID(含后面出现的),用于判断孤立 tool。
allDeclared := make(map[string]bool)
for _, m := range msgs {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
allDeclared[tc.ID] = true
}
}
}
for i, m := range msgs {
if m.Role == "tool" && m.ToolCallID != "" && !allDeclared[m.ToolCallID] {
return true // 孤立 tool
}
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
continue
}
answered := make(map[string]bool, len(m.ToolCalls))
for j := i + 1; j < len(msgs) && msgs[j].Role == "tool"; j++ {
if id := msgs[j].ToolCallID; id != "" {
answered[id] = true
}
}
for _, tc := range m.ToolCalls {
if tc.ID != "" && !answered[tc.ID] {
return true
}
}
}
return false
}