diff --git a/internal/agent/core/msgpair.go b/internal/agent/core/msgpair.go new file mode 100644 index 00000000..3c15a768 --- /dev/null +++ b/internal/agent/core/msgpair.go @@ -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 +} diff --git a/internal/agent/core/msgpair_test.go b/internal/agent/core/msgpair_test.go new file mode 100644 index 00000000..7ddc087a --- /dev/null +++ b/internal/agent/core/msgpair_test.go @@ -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, "|") +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 6ea79f1b..e7bf54dd 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -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,