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,184 @@
package core
// 判据:发送前的 tool_call 配对自检。
//
// ## 守的是什么(2026-11-04 生产故障)
//
// 上游对「assistant(tool_calls) 之后没有对应 tool 结果」是**硬拒绝**:
//
// An assistant message with 'tool_calls' must be followed by tool messages
// responding to each 'tool_call_id'. (insufficient tool messages following...)
//
// 后果:该会话所有 provider 全部失败,用户只看到一句英文报错。
// 且协议**不告诉你是哪条 call** —— 没有定位信息。
//
// 修完渲染层(0ed3c89 预算截断、263b651 并发批次)后,生产 context.json
// 已干净(0 个 result 多于 call 的帧),但 A2A 新会话**仍报同一个 400**
// ⇒ 问题在 f.Msgs(多条路径拼装),不在 L1 历史。
//
// ## 判据为什么必须两类都测
//
// 漏掉"call 无结果" ⇒ 上游 400(本次故障)
// 漏掉"结果无 call" ⇒ 同样 400(反向失配)
//
// 只测一个方向会放过另一半。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
)
func toolMsg(id, content string) agentAPI.Message {
return agentAPI.Message{Role: "tool", ToolCallID: id, Content: content}
}
func asstWithCalls(content string, ids ...string) agentAPI.Message {
tcs := make([]agentAPI.ToolCall, 0, len(ids))
for _, id := range ids {
tcs = append(tcs, agentAPI.ToolCall{ID: id, Name: "tool_" + id})
}
return agentAPI.Message{Role: "assistant", Content: content, ToolCalls: tcs}
}
// TestDropUnpaired_DropsCallWithoutResult 钉住主方向:
// 没有结果的 call 必须被剔掉。
//
// 变异:dropUnpairedToolCalls 直接 return msgs ⇒ 本例红。
func TestDropUnpaired_DropsCallWithoutResult(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "做点事"},
asstWithCalls("", "c1", "c2"), // c2 没有结果
toolMsg("c1", "ok"),
{Role: "user", Content: "继续"},
}
got := dropUnpairedToolCalls(msgs)
// 断言 ①:不许留下未配对的 call
for _, m := range got {
if m.Role != "assistant" {
continue
}
for _, tc := range m.ToolCalls {
if tc.ID == "c2" {
t.Fatalf("★ 未配对的 call c2 仍在消息里 —— 上游必然 400")
}
}
}
// 断言 ②:配对的 c1 必须保留(不能因噎废食全删)
found := false
for _, m := range got {
for _, tc := range m.ToolCalls {
if tc.ID == "c1" {
found = true
}
}
}
if !found {
t.Fatalf("★ 误删了配对的 call c1")
}
// 断言 ③:与其相邻的 tool 结果仍在
if !strings.Contains(joinRoles(got), "tool:c1") {
t.Fatalf("★ 误删了 c1 的结果消息")
}
}
// TestDropUnpaired_DropsOrphanToolResult 钉住反方向:
// 找不到 call 的孤立 tool 结果必须被删。
//
// 变异:删掉第二遍(查孤立 tool)的循环 ⇒ 本例红。
func TestDropUnpaired_DropsOrphanToolResult(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "user", Content: "hi"},
toolMsg("ghost", "我没有任何 call 声明"),
{Role: "user", Content: "继续"},
}
got := dropUnpairedToolCalls(msgs)
for _, m := range got {
if m.Role == "tool" && m.ToolCallID == "ghost" {
t.Fatalf("★ 孤立 tool 结果 ghost 仍在 —— 上游同样 400")
}
}
}
// TestDropUnpaired_LeavesValidSequenceUntouched 钉住"正常路径零改动"。
//
// 这条很重要:修正逻辑若会重排/重写合法消息,就有引入新问题的风险。
func TestDropUnpaired_LeavesValidSequenceUntouched(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "q"},
asstWithCalls("思考", "a", "b"),
toolMsg("a", "ra"),
toolMsg("b", "rb"),
{Role: "user", Content: "next"},
}
before := joinRoles(msgs)
got := dropUnpairedToolCalls(msgs)
if joinRoles(got) != before {
t.Fatalf("★ 合法序列被改动了:\n before=%s\n after =%s", before, joinRoles(got))
}
if len(got) != len(msgs) {
t.Fatalf("★ 合法序列条数变了:%d → %d", len(msgs), len(got))
}
}
// TestDropUnpaired_DropsEmptyAssistantShell 钉住边界:
// call 全被剔且正文为空时,整条 assistant 消息一起删
// (留一条空 assistant 也是非法形态)。
func TestDropUnpaired_DropsEmptyAssistantShell(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "user", Content: "q"},
asstWithCalls("", "x"), // 无结果、无正文
{Role: "user", Content: "cont"},
}
got := dropUnpairedToolCalls(msgs)
for _, m := range got {
if m.Role == "assistant" {
t.Fatalf("★ 留下了空的 assistant 壳(calls 已剔、正文为空)")
}
}
}
// TestDropUnpaired_KeepsContentWhenAllCallsDropped 钉住:
// call 全被剔但**有正文**时,保留正文(那是模型说的话,不能丢)。
func TestDropUnpaired_KeepsContentWhenAllCallsDropped(t *testing.T) {
msgs := []agentAPI.Message{
{Role: "user", Content: "q"},
asstWithCalls("我要先看看", "y"),
{Role: "user", Content: "cont"},
}
got := dropUnpairedToolCalls(msgs)
for _, m := range got {
if m.Role == "assistant" {
if m.Content != "我要先看看" {
t.Fatalf("★ 正文被改了:%q", m.Content)
}
if len(m.ToolCalls) != 0 {
t.Fatalf("★ 还在声明 call")
}
return
}
}
t.Fatalf("★ 有正文的 assistant 被整条删掉了")
}
// joinRoles 把消息序列渲染成 "role:id|role:id" 便于断言。
func joinRoles(msgs []agentAPI.Message) string {
var parts []string
for _, m := range msgs {
s := m.Role
if m.ToolCallID != "" {
s += ":" + m.ToolCallID
}
for _, tc := range m.ToolCalls {
s += "+call:" + tc.ID
}
parts = append(parts, s)
}
return strings.Join(parts, "|")
}