mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
fix(agent): 发送前自检 tool_call 配对 —— 止损并定位上游 400
## 为什么还要修(前面两个修复都不够) 今天已经修了两处:263b651并发批次 stepToolBatch 漏记 ToolCalls0ed3c89L1 预算截断切开 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:
171
internal/agent/core/msgpair.go
Normal file
171
internal/agent/core/msgpair.go
Normal 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
|
||||
}
|
||||
184
internal/agent/core/msgpair_test.go
Normal file
184
internal/agent/core/msgpair_test.go
Normal 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, "|")
|
||||
}
|
||||
@ -746,6 +746,21 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
|
||||
})
|
||||
}
|
||||
|
||||
// ★ 发送前自检:assistant(tool_calls) 必须紧跟它全部的 tool 结果。
|
||||
//
|
||||
// 为什么必须在**这里**(唯一一次组装请求的位置,task.go:750):
|
||||
// 上游对这个约束是硬拒绝(HTTP 400),一句错误信息带不出任何定位信息,
|
||||
// 而 f.Msgs 是**多条路径**拼出来的(prepare 重建、rebase 中断恢复、
|
||||
// 批末 flush、占位补全),任何一条漏掉配对都表现为同一个 400。
|
||||
//
|
||||
// 实测(2026-11-04):历史 context.json 已经干净(0 个 result 多于 call
|
||||
// 的帧),但 A2A 新会话仍报 400 ⇒ 问题不在 L1 历史渲染,在 f.Msgs。
|
||||
//
|
||||
// 这里做的是**止损 + 定位**:把没有配对结果的 tool_calls 剔掉(连同
|
||||
// 该 assistant 消息里其余仍有效的 call 一起重排),并记录被剔的是谁。
|
||||
// 不静默——日志里能看出是哪一轮、哪个工具。
|
||||
f.Msgs = dropUnpairedToolCalls(f.Msgs)
|
||||
|
||||
req := &agentAPI.CompletionRequest{
|
||||
Messages: f.Msgs,
|
||||
MaxTokens: 4096,
|
||||
|
||||
Reference in New Issue
Block a user