mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
生产实测(llmsproxy tier 1,每 5 秒持续复现,非偶发):
api error 400: 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)
根因在 stepToolAfter:它在**批内循环**里对每个工具各调一次,却当场追加
两类附加消息(多模态注入 mediaMsg=user、记忆召回=system)。于是 N 个工具
的批变成
assistant tool_calls=[c1,c2]
tool c1
user ← 插在中间
tool c2 ← 被挤开,与 assistant 不再相邻
上游按「紧跟 assistant 的连续 tool 消息」计数,只数到 1 条却声明了 2 条。
修复:TaskFrame 增加 batchAttachments / batchRecall 积压附加消息,
新增 flushBatchAttachments() 只在**整批 tool 消息全部落完之后**调用。
两个调用点:并行路径 stepToolBatch 收尾循环之后;串行路径 stepToolBegin
发现批已耗尽时。
这也落实了「多模态注入点应在当前轮工具调用之后」这一设计约束。
判据(internal/agent/core/toolcall_adjacency_test.go):
- assertToolCallsAdjacent —— assistant(tool_calls=[c1..cn]) 之后必须**紧接**
n 条 role=tool 且 id 一一对应。既有 TestBatchToolCallIDsAllPaired 只断言
**数量**配对(其注释明确写着「不断言消息的物理排列」),而数量对、排列错
上游照样 400。
- assertMediaNotSilentlyDropped —— 注入的媒体不得静默丢失。
- 三条用例覆盖串行路径 / 并行路径 / 最小回归。
变异验证(M1 当场追加 media / M3 删并行 flush / M4 删串行 flush)全部报红。
其中两轮暴露「判据太松」并已补强:
1. 测试工具未声明 ParallelSafe ⇒ 整批降级走串行,并行判据形同虚设。
补并行专用判据 + 前置断言(不满足即 t.Fatalf,不静默跳过)。
2. 仅断言相邻性时,删掉 flush 反而"更相邻"(附加消息压根没进来),
判据全绿而媒体被**静默丢弃** ⇒ 补 assertMediaNotSilentlyDropped。
教训:只断言一个性质会漏掉另一个,相邻性与不丢失必须同时断言。
验证:core 包三次全绿 + -race 干净;全仓 48 包 0 FAIL;
端到端真实多工具并行对话正常完成,日志 insufficient tool messages = 0 次。
296 lines
11 KiB
Go
296 lines
11 KiB
Go
package core
|
||
|
||
// 本文件钉死「assistant(tool_calls) 与其 tool 结果消息的**相邻性**」。
|
||
//
|
||
// ## 为何单独立这一组(2026-10-13)
|
||
//
|
||
// 生产报错(llmsproxy tier 1,持续复现):
|
||
//
|
||
// api error 400: 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)
|
||
//
|
||
// 仓内已有 TestBatchToolCallIDsAllPaired,但它只断言**数量**配对,
|
||
// 注释还明确写着「不断言消息的物理排列」。而 OpenAI 协议要求的是
|
||
// **assistant(tool_calls=[c1..cn]) 之后紧接 n 条 role=tool**,
|
||
// 中间不得插入任何其他角色的消息(system/user 都不行)。
|
||
//
|
||
// 数量对而排列错,上游照样判 400 —— 这正是上面那条生产错误的形态。
|
||
//
|
||
// ## 缺陷位置(本判据要咬住的东西)
|
||
//
|
||
// stepToolAfter 每落一条 tool 消息后,会追加两类附加消息:
|
||
//
|
||
// f.Msgs = append(f.Msgs, toolMsg) // tool(i)
|
||
// if mediaMsg != nil { append(*mediaMsg) } // ← user 消息(多模态注入)
|
||
// if recallText != "" { appendOrReplaceRecall() } // ← system 消息(记忆召回)
|
||
//
|
||
// 而 stepToolAfter 在**批内循环**里对每个工具各调一次。于是 N 个工具的批会变成:
|
||
//
|
||
// assistant(tool_calls=[c0,c1])
|
||
// tool(c0)
|
||
// system(召回) ← 插在中间,c1 的 tool 消息与 assistant 不再相邻
|
||
// tool(c1)
|
||
//
|
||
// 用户明确的设计约束:**多模态注入点应在「当前轮工具调用」之后**
|
||
// —— 即整批的 tool 消息落完之后,而不是每条 tool 之后。
|
||
|
||
import (
|
||
"strconv"
|
||
"sync"
|
||
"testing"
|
||
|
||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
|
||
)
|
||
|
||
// visionBatchProvider 在 batchProvider 之上声明「能看图」。
|
||
//
|
||
// 为何需要它:prepareToolBlocks 只在 provider 支持该模态时才产出 native blocks
|
||
// (否则走文字回退,不产生 mediaMsg)—— 要复现多模态注入这条路径,必须让
|
||
// provider 支持 vision。
|
||
type visionBatchProvider struct{ *batchProvider }
|
||
|
||
func (p *visionBatchProvider) SupportsVision() bool { return true }
|
||
func (p *visionBatchProvider) SupportsAudio() bool { return true }
|
||
|
||
// assertToolCallsAdjacent 断言:每条带 tool_calls 的 assistant 消息之后,
|
||
// 必须**紧接** len(tool_calls) 条 role=tool 消息,且 id 与声明一一对应。
|
||
//
|
||
// 返回违规描述(空切片 = 通过)。
|
||
func assertToolCallsAdjacent(msgs []agentAPI.Message) []string {
|
||
var problems []string
|
||
for i := 0; i < len(msgs); i++ {
|
||
m := msgs[i]
|
||
if m.Role != "assistant" || len(m.ToolCalls) == 0 {
|
||
continue
|
||
}
|
||
want := len(m.ToolCalls)
|
||
// 逐条检查紧随其后的 want 条消息。
|
||
for k := 1; k <= want; k++ {
|
||
j := i + k
|
||
if j >= len(msgs) {
|
||
problems = append(problems, problemAt(i, k, "消息序列在配对完成前结束"))
|
||
break
|
||
}
|
||
if msgs[j].Role != "tool" {
|
||
problems = append(problems, problemAt(i, k,
|
||
"第 "+strconv.Itoa(k)+" 条紧随消息是 role="+msgs[j].Role+"(期望 tool)"))
|
||
break
|
||
}
|
||
if msgs[j].ToolCallID != m.ToolCalls[k-1].ID {
|
||
problems = append(problems, problemAt(i, k,
|
||
"tool_call_id 不匹配:声明 "+m.ToolCalls[k-1].ID+" 实得 "+msgs[j].ToolCallID))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
func problemAt(msgIdx, k int, detail string) string {
|
||
return "assistant@[" + strconv.Itoa(msgIdx) + "] 的配对断裂(第 " + strconv.Itoa(k) + " 条):" + detail
|
||
}
|
||
|
||
// assertMediaNotSilentlyDropped 断言:注入的多模态块必须真的出现在消息序列里。
|
||
//
|
||
// ★ 为何与相邻性分开断言(2026-10-13 踩到):只测相邻性时,把
|
||
//
|
||
// flushBatchAttachments 整个删掉反而"更相邻"(附加消息压根没进来)——
|
||
// 判据全绿,而媒体被**静默丢弃**。两个性质必须同时断言:
|
||
// ① 媒体不得丢(丢了是功能失效)
|
||
// ② 媒体必须在整批 tool 消息**之后**(位置错了是协议违规)
|
||
func assertMediaNotSilentlyDropped(t *testing.T, msgs []agentAPI.Message) {
|
||
t.Helper()
|
||
for _, m := range msgs {
|
||
if m.Role == "user" && len(m.Blocks) > 0 {
|
||
return
|
||
}
|
||
}
|
||
t.Errorf("工具注入了多模态块,但消息序列里没有任何带 Blocks 的 user 消息"+
|
||
"(媒体被静默丢弃)\n%s", renderMsgRoles(msgs))
|
||
}
|
||
|
||
// TestBatchMediaInjectionKeepsToolPairingAdjacent 钉死用户明确的设计约束:
|
||
//
|
||
// 「多模态注入点应该在当前轮工具调用后」
|
||
//
|
||
// 即:整批的 tool 消息先落完,才允许挂多模态 user 消息;
|
||
// 而不是每落一条 tool 就挂一次 —— 那样第二条 tool 消息会被挤开,
|
||
// assistant 的 tool_calls 与它的结果不再相邻,上游判
|
||
// 「insufficient tool messages following tool_calls message」(生产实测)。
|
||
func TestBatchMediaInjectionKeepsToolPairingAdjacent(t *testing.T) {
|
||
tcA := agentAPI.ToolCall{ID: "c1", Name: "tool_alpha", Arguments: map[string]interface{}{}}
|
||
tcB := agentAPI.ToolCall{ID: "c2", Name: "tool_beta", Arguments: map[string]interface{}{}}
|
||
sp := &visionBatchProvider{&batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{tcA, tcB}},
|
||
{Content: "final"},
|
||
}}}
|
||
|
||
// 自己装配 agent:**不能**用 newBatchAgent —— 它已经把 tool_alpha/tool_beta
|
||
// 注册在 batchdev 上,再注册第二个设备会被工具解析忽略(本判据第一版
|
||
// 就因此"前置条件不成立")。这里注册唯一的设备,并在其 toolFn 里注入块。
|
||
a := New(AgentConfig{
|
||
ID: "mediaagent",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: NewStageHost(),
|
||
})
|
||
var mu sync.Mutex
|
||
dev := &mockOutputDevice{
|
||
name: "mediadev",
|
||
caps: agentIO.CapText,
|
||
tools: []agentIO.ToolDef{
|
||
{Name: "tool_alpha", Description: "a"},
|
||
{Name: "tool_beta", Description: "b"},
|
||
},
|
||
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
// 每个工具都注入一个多模态块(模拟截图/看图类工具)。
|
||
a.io.SetToolBlocks([]interface{}{
|
||
pubsdk.ContentBlock{
|
||
Type: "image_url",
|
||
ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AAAA"},
|
||
},
|
||
})
|
||
return "ran:" + tool, nil
|
||
},
|
||
}
|
||
if err := a.io.RegisterDevice(dev); err != nil {
|
||
t.Fatalf("注册测试设备失败: %v", err)
|
||
}
|
||
|
||
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
|
||
if out := a.runTaskSteps(f); out != outcomeDone {
|
||
t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err)
|
||
}
|
||
|
||
assertMediaNotSilentlyDropped(t, f.Msgs)
|
||
|
||
if probs := assertToolCallsAdjacent(f.Msgs); len(probs) > 0 {
|
||
for _, p := range probs {
|
||
t.Errorf("配对相邻性违反:%s", p)
|
||
}
|
||
t.Errorf("完整消息序列:\n%s", renderMsgRoles(f.Msgs))
|
||
}
|
||
}
|
||
|
||
// TestBatchParallelPathAlsoKeepsAdjacency 覆盖**并行**路径的 flush 点。
|
||
//
|
||
// 为何必须单独一条:并行与串行是 stepToolBatch / stepToolBegin 两个不同的
|
||
// 收尾点,各自调用 flushBatchAttachments。只测一条路径会让另一条的 flush
|
||
// 被删掉也不报红(本判据第一版即如此:测试工具未声明 ParallelSafe,
|
||
// 整批降级走串行,M3 变异删掉并行 flush 后仍全绿)。
|
||
func TestBatchParallelPathAlsoKeepsAdjacency(t *testing.T) {
|
||
tcA := agentAPI.ToolCall{ID: "p1", Name: "par_alpha", Arguments: map[string]interface{}{}}
|
||
tcB := agentAPI.ToolCall{ID: "p2", Name: "par_beta", Arguments: map[string]interface{}{}}
|
||
sp := &visionBatchProvider{&batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{tcA, tcB}},
|
||
{Content: "final"},
|
||
}}}
|
||
a := New(AgentConfig{
|
||
ID: "paragent",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: NewStageHost(),
|
||
})
|
||
var mu sync.Mutex
|
||
dev := &mockOutputDevice{
|
||
name: "pardev",
|
||
caps: agentIO.CapText,
|
||
tools: []agentIO.ToolDef{
|
||
// ★ ParallelSafe:true 是全批走并行路径的必要条件(三条件之一)。
|
||
{Name: "par_alpha", Description: "a", ParallelSafe: true},
|
||
{Name: "par_beta", Description: "b", ParallelSafe: true},
|
||
},
|
||
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
a.io.SetToolBlocks([]interface{}{
|
||
pubsdk.ContentBlock{
|
||
Type: "image_url",
|
||
ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AAAA"},
|
||
},
|
||
})
|
||
return "ran:" + tool, nil
|
||
},
|
||
}
|
||
if err := a.io.RegisterDevice(dev); err != nil {
|
||
t.Fatalf("注册测试设备失败: %v", err)
|
||
}
|
||
|
||
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
|
||
if out := a.runTaskSteps(f); out != outcomeDone {
|
||
t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err)
|
||
}
|
||
// 前置断言:批内两枚工具都声明了 ParallelSafe,且 >1 个 ⇒ 必须走并行路径。
|
||
// 若这条不成立,说明并行判定变了,本判据就不再覆盖并行 flush —— 那是
|
||
// 「测了等于没测」,必须响亮失败而不是静默通过。
|
||
if !f.batchRunnable(a) {
|
||
t.Fatalf("本批未走并行路径(两枚工具均已声明 ParallelSafe),"+
|
||
"本判据失去覆盖目标\n%s", renderMsgRoles(f.Msgs))
|
||
}
|
||
assertMediaNotSilentlyDropped(t, f.Msgs)
|
||
if probs := assertToolCallsAdjacent(f.Msgs); len(probs) > 0 {
|
||
for _, p := range probs {
|
||
t.Errorf("并行路径配对相邻性违反:%s", p)
|
||
}
|
||
t.Errorf("完整消息序列:\n%s", renderMsgRoles(f.Msgs))
|
||
}
|
||
}
|
||
|
||
// TestBatchToolMessagesAdjacentToAssistant 是最小回归:两个工具的批,
|
||
// assistant 之后必须紧跟两条 tool 消息。
|
||
func TestBatchToolMessagesAdjacentToAssistant(t *testing.T) {
|
||
tcA := agentAPI.ToolCall{ID: "c1", Name: "tool_alpha", Arguments: map[string]interface{}{}}
|
||
tcB := agentAPI.ToolCall{ID: "c2", Name: "tool_beta", Arguments: map[string]interface{}{}}
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{tcA, tcB}},
|
||
{Content: "final"},
|
||
}}
|
||
a, _ := newBatchAgent(t, sp)
|
||
|
||
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
|
||
if out := a.runTaskSteps(f); out != outcomeDone {
|
||
t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err)
|
||
}
|
||
|
||
if probs := assertToolCallsAdjacent(f.Msgs); len(probs) > 0 {
|
||
for _, p := range probs {
|
||
t.Errorf("配对相邻性违反:%s", p)
|
||
}
|
||
t.Errorf("完整消息序列:\n%s", renderMsgRoles(f.Msgs))
|
||
}
|
||
}
|
||
|
||
// renderMsgRoles 把消息序列渲染成一行,便于失败时肉眼定位。
|
||
func renderMsgRoles(msgs []agentAPI.Message) string {
|
||
out := ""
|
||
for i, m := range msgs {
|
||
if i > 0 {
|
||
out += "\n"
|
||
}
|
||
out += " [" + strconv.Itoa(i) + "] " + m.Role
|
||
if len(m.ToolCalls) > 0 {
|
||
out += " tool_calls=["
|
||
for k, tc := range m.ToolCalls {
|
||
if k > 0 {
|
||
out += ","
|
||
}
|
||
out += tc.ID
|
||
}
|
||
out += "]"
|
||
}
|
||
if m.ToolCallID != "" {
|
||
out += " tool_call_id=" + m.ToolCallID
|
||
}
|
||
if m.Role == "system" {
|
||
out += " (system)"
|
||
}
|
||
}
|
||
return out
|
||
}
|