fix(agent): 批内附加消息延迟到整批落完,修复上游 tool_calls 配对 400

生产实测(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 次。
This commit is contained in:
JianFeeeee
2026-10-13 16:16:50 +08:00
parent c7da8fde82
commit ec035951cc
2 changed files with 345 additions and 4 deletions

View File

@ -146,6 +146,23 @@ type TaskFrame struct {
// tool_calls,工具结果各自作为 tool 消息追加在它之后。
assistantMsgIdx int
// batchAttachments / batchRecall 是本批**积压的附加消息**,
// 只在整批 tool 消息全部落完之后才由 flushBatchAttachments 追加。
//
// ★ 为何必须积压而不是当场追加(2026-10-13 生产 400 事故):
// OpenAI 协议要求 assistant(tool_calls=[c1..cn]) 之后**紧接** n 条
// role=tool,中间不得插入任何其他角色。而 stepToolAfter 在批内循环里
// 对**每个工具各调一次** —— 若它当场就把 mediaMsg(user)/召回(system)
// 追加进去,第一条 tool 之后就被塞进一条其他角色的消息,于是第二条
// tool 不再与 assistant 相邻。上游实测报:
// 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)
//
// 用户明确的设计约束:「多模态注入点应该在当前轮工具调用后」。
batchAttachments []agentAPI.Message
batchRecall string
// CurRaw 是本次执行的**未降级**返回值(interface{})。
// 存在理由:CurResult 是 string,结构化信息在此被抹平,导致 Success
// 无法诚实化、after_toolcall 的改写静默失效。
@ -868,6 +885,9 @@ func (a *Agent) stepToolBatch(f *TaskFrame) stepOutcome {
return out
}
}
// ★ 整批 tool 消息已全部落完,此刻才能追加多模态/召回附加消息
//(否则会切断 assistant(tool_calls) 与其结果的相邻性,见 batchAttachments)。
f.flushBatchAttachments()
f.ToolIdx = n
f.Step = StepTurnEnd
return outcomeContinue
@ -930,6 +950,9 @@ func (a *Agent) runOneTool(f *TaskFrame, idx int) batchItemResult {
// stepToolBegin 取本批下一个工具;批已耗尽或发生中断则进入收尾。
func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
if f.ToolIdx >= len(f.PendingTools) {
// ★ 批已耗尽(本批全部走串行路径):同样只能在**这里**追加附加消息,
// 理由与并行路径一致(见 batchAttachments)。
f.flushBatchAttachments()
f.Step = StepTurnEnd
return outcomeContinue
}
@ -1129,12 +1152,15 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
f.noteOversizeTool(tc.Name)
}
if mediaMsg != nil {
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
f.Msgs = append(f.Msgs, *mediaMsg)
// 延迟到批末(见 batchAttachments 的注释):当场追加会把后续 tool 消息
// 与 assistant 挤开,上游判 insufficient tool messages。
f.batchAttachments = append(f.batchAttachments, *mediaMsg)
}
// 召回作为 system 消息挂在末尾(tool/assistant 配对已完成,插入此处不断链)。
// 召回同样延迟到批末:它是 system 消息,同样会断开 tool 配对。
// (语义与原先一致:同一批内多条召回仍是最后一条生效,因为
// appendOrReplaceRecall 本就是替换而非累加。)
if recallText != "" {
f.Msgs = appendOrReplaceRecall(f.Msgs, recallText)
f.batchRecall = recallText
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
@ -1151,6 +1177,26 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
return outcomeContinue
}
// flushBatchAttachments 把本批积压的附加消息一次性追加到 Msgs 末尾。
//
// 必须在**整批 tool 消息全部落完之后**调用,否则会切断
// assistant(tool_calls) 与 tool 结果消息的相邻性(见 batchAttachments 注释)。
// 两个调用点:并行路径 stepToolBatch 的收尾循环之后;串行路径 stepToolBegin
// 发现批已耗尽时。
func (f *TaskFrame) flushBatchAttachments() {
if len(f.batchAttachments) == 0 && f.batchRecall == "" {
return
}
for _, m := range f.batchAttachments {
f.Msgs = append(f.Msgs, m)
}
f.batchAttachments = nil
if f.batchRecall != "" {
f.Msgs = appendOrReplaceRecall(f.Msgs, f.batchRecall)
f.batchRecall = ""
}
}
// ensureBatchAssistant 保证本批有且只有**一条**带 tool_calls 的 assistant 消息。
//
// 阶段 2a 的落法:批内「一个 assistant 携带全部 tool_calls」+ N 条 tool 消息。