Files
HomeAgent/internal/agent/core/msgpair_test.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

185 lines
5.6 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
// 判据:发送前的 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, "|")
}