mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
fix(agent): 并发批次漏记 ToolCalls —— 修上游 400 与 A2A 会话不可用
## 故障现象
生产 A2A 只回一句错误:
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 全部失败,那条会话彻底不可用。
## 排查方法:读内核自己写的日志
内核每次启动写一份 homed_<时间>.log。在其中看到:
overflow check #1: ratio=1.21 events=41 (threshold=1.25)
上下文**没有**触发裁剪(1.21 < 1.25)——所以不是裁剪切断配对。
于是查 context.json 的实际数据,按 call_id 配对统计,发现 4 个事件
出现"结果多于调用":
[17] calls=18 results=20
[22] calls= 1 results= 3
[28] calls=14 results=20
[35] calls= 5 results= 7
## 根因
帧里有两条执行路径,各自记录:
stepToolBegin → appendToolCallRecord(tc) ← 记 ToolCalls + ToolsUsed
stepToolExec → append ToolResults (串行路径,两条都记)
stepToolBatch → append ToolResults (并发路径,只记结果!)
并发路径(stepToolBatch)**从不调 appendToolCallRecord**。
于是落进 ContextEvent 的数据里 ToolResults 多于 ToolCalls。渲染时
每条 result 都生成一条 role="tool" 消息,而配对的 assistant(tool_calls)
里没有这个 call_id ⇒ 上游 400 ⇒ 所有 provider 失败。
同时还漏了 ToolsUsed(它与 ToolCalls 在 appendToolCallRecord 里同处维护):
并发批次的工具从未被记进"用过的工具",记忆/统计/页化都在少算。
## 为什么不是本次内核升级引入
对比日志:ba8a33d(升级前)今天也有 3 次同样错误。它是既有缺陷,
只是并发批次越多越容易触发。本次排查顺带定位并修掉。
## 修法
在 join 之后的**顺序收尾段**补一行 appendToolCallRecord:
f.appendToolCallRecord(f.CurTool)
f.ToolResults = append(...)
为什么放这里而不是 fan-out 里:函数注释已明确 f.Msgs / f.ToolResults
在 goroutine 期间一律不碰。这里是按索引串行的收尾段,与单步路径语义一致。
## 判据 TestBatchContextEventPairsCallsWithResults
三条:调用记录齐全、每条结果有配对、每个调用有结果(双向)。
★ 已有 TestBatchToolCallIDsAllPaired 没抓到,因为它只看 f.Msgs ——
而 Msgs 里的 assistant 消息由 ensureBatchAssistant 单独写入(含全部
PendingTools),不经过 f.ToolCalls。所以 Msgs 侧配平、落盘侧不配平。
判据必须落在**要落盘的那份数据**上。
## 变异验证(两次才对)
第一次:删掉修复行 ⇒ 判据**没红**。原因:测试里的工具没声明
ParallelSafe,batchRunnable 判 false ⇒ 走串行路径(stepToolExec),
根本没测到目标代码。
第二次:给工具加 ParallelSafe + 断言 ConcurrencySafeOf 为真
(确保真的走并发),再删修复行 ⇒
★ f.ToolCalls 只有 0 个(map[]),期望 2 个
这才是有效验证。教训:**判据必须断言自己走到了目标路径**,
否则"删掉实现仍绿"会被误读成"实现不必要"。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + TestBatch* 全绿
This commit is contained in:
@ -982,6 +982,26 @@ func (a *Agent) stepToolBatch(f *TaskFrame) stepOutcome {
|
||||
f.CurResult = r.text
|
||||
f.CurRaw = r.raw
|
||||
f.ToolIdx = i
|
||||
// ★ 必须先记录**调用**,再记结果(2026-11-04 生产故障修复)。
|
||||
//
|
||||
// 此前的缺陷:并发路径只 append ToolResults,**从不调
|
||||
// appendToolCallRecord** ⇒ 上下文里 ToolResults 多于 ToolCalls。
|
||||
//
|
||||
// 后果链(实测):
|
||||
// 渲染时每个 tool result 都会产生一条 role="tool" 消息,
|
||||
// 而与其配对的 assistant(tool_calls) 里没有这个 call_id
|
||||
// ⇒ 上游 400:An assistant message with 'tool_calls' must be
|
||||
// followed by tool messages responding to each 'tool_call_id'
|
||||
// ⇒ 所有 provider 全部失败,A2A 会话彻底不可用。
|
||||
//
|
||||
// 同时漏掉的还有 ToolsUsed(它和 ToolCalls 在 appendToolCallRecord
|
||||
// 里同处维护):并发批次的工具从未被记进"用过的工具",
|
||||
// 记忆/统计/页化都在少算。
|
||||
//
|
||||
// 为什么放在这里而不是 fan-out 里:注释已说明 f.Msgs / f.ToolResults
|
||||
// 在 goroutine 期间一律不碰。这里是 join 之后的顺序收尾段,
|
||||
// 按索引串行,与单步路径的语义一致。
|
||||
f.appendToolCallRecord(f.CurTool)
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: r.name, Output: r.text})
|
||||
if out := a.stepToolAfter(f); out != outcomeContinue {
|
||||
return out
|
||||
|
||||
@ -402,3 +402,100 @@ func TestBatchAfterToolcallSeesOwnResult(t *testing.T) {
|
||||
t.Errorf("after_toolcall 读到别的工具的结果: %v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:并发批次的 **ToolCalls 与 ToolResults 必须配对**。
|
||||
//
|
||||
// ## 这条判据补的是一个真实生产故障(2026-11-04)
|
||||
//
|
||||
// stepToolBatch 只 append ToolResults,**从不调 appendToolCallRecord**
|
||||
// ⇒ 帧里的 ToolCalls 少于 ToolResults。
|
||||
//
|
||||
// 后果链:
|
||||
//
|
||||
// finishInputTask 把 f.ToolCalls / f.ToolResults 原样写进 ContextEvent
|
||||
// ⇒ 渲染时每条 tool result 产生一条 role="tool" 消息,
|
||||
// 而配对的 assistant(tool_calls) 里没有该 call_id
|
||||
// ⇒ 上游 400 "must be followed by tool messages responding to each tool_call_id"
|
||||
// ⇒ 所有 provider 失败 ⇒ A2A 长会话彻底不可用
|
||||
//
|
||||
// ## 为什么已有的 TestBatchToolCallIDsAllPaired 没抓到
|
||||
//
|
||||
// 它只看 `f.Msgs`。而 Msgs 里的 assistant 消息由 ensureBatchAssistant
|
||||
// 单独写入(含全部 PendingTools),**不经过 f.ToolCalls** ——
|
||||
// 所以 Msgs 侧配平,落 context 的那份数据不配平。
|
||||
//
|
||||
// ## 为什么查 f.ToolCalls 而不是 a.context
|
||||
//
|
||||
// process() 走 runTaskSteps 直接返回,**不经过 finishInputTask**,
|
||||
// 因此不会写 context。而 f.ToolCalls / f.ToolResults 正是
|
||||
// finishInputTask 原样搬进 ContextEvent 的两个字段
|
||||
// (task.go:605-606 `ToolResults: f.ToolResults, ToolCalls: f.ToolCalls`)——
|
||||
// 查它们等价于查落盘数据,且不依赖完整调度循环。
|
||||
func TestBatchContextEventPairsCallsWithResults(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)
|
||||
|
||||
// ★ 必须让工具声明 ParallelSafe,否则 batchRunnable 判false ⇒ 走
|
||||
// 串行路径(stepToolExec),而 bug 只在**并发路径**(stepToolBatch)里。
|
||||
// 第一版判据没声明,删掉修复行仍然通过 —— 那次验证无效。
|
||||
for _, name := range []string{"tool_alpha", "tool_beta"} {
|
||||
if err := a.stageHost.RegisterTool(name, sdk.ToolDef{
|
||||
Name: name, Description: name, ParallelSafe: true,
|
||||
Parameters: map[string]interface{}{"type": "object"},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
t.Fatalf("注册 %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
|
||||
if out := a.runTaskSteps(f); out != outcomeDone {
|
||||
t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err)
|
||||
}
|
||||
|
||||
// 断言真的走了并发路径(否则本判据测不到目标代码)
|
||||
if a.stageHost != nil {
|
||||
if safe, ok := a.stageHost.ConcurrencySafeOf("tool_alpha"); !ok || !safe {
|
||||
t.Fatal("前置不成立:tool_alpha 未被判为并发安全,本判据会走串行路径而测不到 bug")
|
||||
}
|
||||
}
|
||||
|
||||
// ① 调用记录必须齐全(并发路径漏 appendToolCallRecord 就会少于 2)
|
||||
declared := map[string]bool{}
|
||||
for _, tc := range f.ToolCalls {
|
||||
declared[tc.CallID] = true
|
||||
}
|
||||
if len(declared) != 2 {
|
||||
t.Fatalf("★ f.ToolCalls 只有 %d 个(%v),期望 2 个。\n"+
|
||||
"并发路径(stepToolBatch)漏了 appendToolCallRecord。\n"+
|
||||
"下游后果:落进 ContextEvent 后,渲染出无配对的 role=\"tool\" 消息\n"+
|
||||
"⇒ 上游 400 must be followed by tool messages ⇒ 所有 provider 失败。",
|
||||
len(declared), declared)
|
||||
}
|
||||
|
||||
// ② 每条结果都必须有配对
|
||||
for _, tr := range f.ToolResults {
|
||||
if !declared[tr.CallID] {
|
||||
t.Fatalf("★ 工具结果 %q (call_id=%s) 在 ToolCalls 里找不到配对 ——\n"+
|
||||
"这正是生产故障的成因:上游会拒绝这样的消息序列。\n"+
|
||||
"declared=%v", tr.Name, tr.CallID, declared)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 双向:每个调用也必须有结果(否则是悬空 call,上游同样拒绝)
|
||||
answered := map[string]bool{}
|
||||
for _, tr := range f.ToolResults {
|
||||
answered[tr.CallID] = true
|
||||
}
|
||||
for id := range declared {
|
||||
if !answered[id] {
|
||||
t.Fatalf("★ 调用 %s 没有对应结果 —— 悬空 tool_call,上游会拒绝", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user