mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
@ -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 消息。
|
||||
|
||||
Reference in New Issue
Block a user