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:
JianFeeeee
2026-11-04 14:02:58 +08:00
parent a8ce5ece30
commit 263b651bf0
2 changed files with 117 additions and 0 deletions

View File

@ -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