From 28b42bcf0f33c9ccc09cbb416e6594f3cdc3755c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 11:09:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor(toolcall):=20=E6=89=B9=E5=86=85?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=94=B9=E4=B8=BA=E3=80=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=20assistant=20=E5=B8=A6=E5=85=A8=E9=83=A8=20tool=5Fcalls?= =?UTF-8?q?=E3=80=8D=EF=BC=88=E9=98=B6=E6=AE=B5=202a=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:现状每个工具各自 append 一对(assistant[tool_calls=[tc]] + tool), 既不表达「这是一批」,也无法支撑并行: · 产生 N 条 assistant 消息,同一段 assistant 文本语义上只该出现一次 · 并行下完成顺序不确定,若等结果回来再落消息,assistant 就必须等所有 结果齐了才能写——而 OpenAI 协议要求 assistant(tool_calls) 在结果**之前** 改动(task.go): · TaskFrame 增 assistantMsgIdx · 新增 ensureBatchAssistant:惰性写入,全批只写**一条** assistant, 携带 f.PendingTools 全部 tool_calls;后续工具只补 tool 消息 · stepToolBegin 的 denied / unhealthy 分支与 stepToolAfter 统一改用它 · stepLLM 设 PendingTools 时清零 assistantMsgIdx · msgContent 的 ContentOnce 归位移入 ensureBatchAssistant(仍是只挂第一条) ⚠️ 依赖:before_toolcall 阶段**不得**改写工具参数——已核实全仓无此用法 (grep ToolCalls[0].Arguments 赋值无结果)。若将来某插件要改写 args, 需改为「回填后重写该条 assistant」。该前提已写入代码注释。 判据(toolbatch_test.go 追加 TestBatchLayoutSingleAssistantCarriesAllToolCalls): · 带 tool_calls 的 assistant **恰好一条**且携带 2 个 tool_calls · 其后紧跟 2 条 tool 消息且按声明顺序(c1、c2) 变异验证:让 ensureBatchAssistant 退化为「每工具一条」⇒ 判据 FAIL 「批内 assistant 应带 2 个 tool_calls,实际 1」。 stage 0.5 补的三条判据(配对完整性 / ContentOnce / denied 后继续) 在本改动后**仍然全绿**——它们正是为这种改动准备的保护网。 回归:internal/agent/... internal/sdk/... internal/plugins/... 全绿(18 包)。 --- internal/agent/core/task.go | 59 +++++++++++++++++++++------ internal/agent/core/toolbatch_test.go | 54 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index fff8fe6..419cdee 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -110,6 +110,11 @@ type TaskFrame struct { CurTool agentAPI.ToolCall CurToolPlugin string CurResult string + // assistantMsgIdx 是本批 assistant(tool_calls) 消息在 Msgs 中的下标, + // -1 表示尚未写入。阶段 2a:批内只写**一条** assistant 承载全部 + // tool_calls,工具结果各自作为 tool 消息追加在它之后。 + assistantMsgIdx int + // CurRaw 是本次执行的**未降级**返回值(interface{})。 // 存在理由:CurResult 是 string,结构化信息在此被抹平,导致 Success // 无法诚实化、after_toolcall 的改写静默失效。 @@ -677,6 +682,16 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { } f.ContentOnce = true f.PendingTools = resp.ToolCalls + // 阶段 2a:批内消息**预置**为「一个 assistant 带全部 tool_calls」。 + // + // 为何预置而不是逐步 append:并行执行下多个工具的**完成顺序不确定**, + // 若等结果回来再落消息,assistant 就必须等所有结果齐了才能写; + // 而 OpenAI 协议要求 assistant(tool_calls) 在**结果之前**。 + // 预置同时让 assistant 只出现一次(逐步 append 会产生 N 条)。 + // + // ⚠️ 前提:before_toolcall 阶段不得改写工具参数(已核实全仓无此用法)。 + // 若某插件将来要改写 args,需在这里改为「回填后重写该条 assistant」。 + f.assistantMsgIdx = -1 f.ToolIdx = 0 f.Step = StepToolBegin return outcomeContinue @@ -702,7 +717,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome { f.StageCtx.ToolResults = nil if a.runStage(sdk.StageBeforeToolcall, f.StageCtx) { result := denialResultText(f.StageCtx, tc.Name) - f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}}) + f.ensureBatchAssistant() f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}) a.publishEvent(events.EventToolCall, map[string]interface{}{ "tool": tc.Name, @@ -720,7 +735,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome { if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) { result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName) log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName) - f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}}) + f.ensureBatchAssistant() f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}) f.ToolIdx++ return outcomeContinue @@ -817,16 +832,7 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { } } - msgContent := "" - if f.ContentOnce { - msgContent = f.Resp.Content - f.ContentOnce = false - } - f.Msgs = append(f.Msgs, agentAPI.Message{ - Role: "assistant", Content: msgContent, - ReasoningContent: f.Resp.ReasoningContent, - ToolCalls: []agentAPI.ToolCall{tc}, - }) + f.ensureBatchAssistant() // 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。 // @@ -902,6 +908,35 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome { return outcomeContinue } +// ensureBatchAssistant 保证本批有且只有**一条**带 tool_calls 的 assistant 消息。 +// +// 阶段 2a 的落法:批内「一个 assistant 携带全部 tool_calls」+ N 条 tool 消息。 +// 惰性写入(首次调用时才 append)而不是在 stepLLM 预置,因为: +// +// · 预置会让「全部工具都被拒绝/崩溃」这类零执行分支也留下一条空 assistant +// (虽然无害,但会给模型一条没有结果的 tool_calls,个别网关会报错) +// · assistant 文本(ContentOnce)要挂在**第一条**上,而那要等到有结果才知道 +// +// 并行下完成顺序不确定,因此 assistant 必须**先于**任何 tool 消息存在; +// 惰性写入天然满足:第一个完成的工具触发写入,后续只补 tool 消息。 +func (f *TaskFrame) ensureBatchAssistant() { + if f.assistantMsgIdx >= 0 { + return + } + msgContent := "" + if f.ContentOnce && f.Resp != nil { + msgContent = f.Resp.Content + f.ContentOnce = false + } + f.assistantMsgIdx = len(f.Msgs) + f.Msgs = append(f.Msgs, agentAPI.Message{ + Role: "assistant", + Content: msgContent, + ReasoningContent: f.Resp.ReasoningContent, + ToolCalls: f.PendingTools, + }) +} + // stepTurnEnd 收尾本批并进入下一轮。 func (a *Agent) stepTurnEnd(f *TaskFrame) stepOutcome { // 供下一轮顶部选择补位文案。 diff --git a/internal/agent/core/toolbatch_test.go b/internal/agent/core/toolbatch_test.go index 2997da9..3d238fa 100644 --- a/internal/agent/core/toolbatch_test.go +++ b/internal/agent/core/toolbatch_test.go @@ -245,3 +245,57 @@ func TestBatchContinuesAfterDeniedTool(t *testing.T) { t.Errorf("tool_beta 的结果未回填(被前一工具的拒绝连带丢弃)") } } + +// 阶段 2a:消息落法改为「**一个** assistant 带全部 tool_calls + N 条 tool」。 +// +// 现状:每个工具各自 append 一对(assistant[tool_calls=[tc]] + tool), +// 不表达「这是一批」。阶段 2 的批内并发要求消息形态与之对应,且并行下 +// 多个 tool message 的相对顺序必须**按 index 确定**,否则模型读到的 +// 上下文顺序 ≠ 执行顺序,会诱导出错误的因果推断。 +// +// 本判据钉死:新布局下(a)配对仍完整、(b)assistant 只出现一条且带全部 +// tool_calls、(c)tool 消息按 index 升序、(d)多模态 user 消息仍紧跟 +// 各自的 tool 消息。 +func TestBatchLayoutSingleAssistantCarriesAllToolCalls(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{ + {Content: "BATCHTEXT", 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) + } + + // ① 找带 tool_calls 的 assistant 消息,必须**恰好一条**且带 2 个。 + var assistants []int + for i, m := range f.Msgs { + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + assistants = append(assistants, i) + if len(m.ToolCalls) != 2 { + t.Errorf("批内 assistant 应带 2 个 tool_calls,实际 %d", len(m.ToolCalls)) + } + } + } + if len(assistants) != 1 { + t.Fatalf("带 tool_calls 的 assistant 应恰好 1 条,实际 %d 条(索引 %v)", len(assistants), assistants) + } + + // ② 该 assistant 之后应紧跟 2 条 tool 消息,且按声明顺序。 + idx := assistants[0] + var gotIDs []string + for i := idx + 1; i < len(f.Msgs); i++ { + if f.Msgs[i].Role == "tool" { + gotIDs = append(gotIDs, f.Msgs[i].ToolCallID) + } + } + if len(gotIDs) != 2 { + t.Fatalf("assistant 之后应有 2 条 tool 消息,实际 %d(%v)", len(gotIDs), gotIDs) + } + if gotIDs[0] != "c1" || gotIDs[1] != "c2" { + t.Errorf("tool 消息应按 index 升序,实际 %v", gotIDs) + } +}