Files
HomeAgent/internal/agent/core/msgpair.go
JianFeeeee 4ab888ce06 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 与工具名就指向真正漏配对的
那条路径,据此再修上游。
2026-11-04 15:58:22 +08:00

172 lines
5.4 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
// 发送前的消息配对自检(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
}