From 4ba72977d2d6bca61c55fd31c9d306d4af453a38 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 27 Sep 2026 08:59:30 +0800 Subject: [PATCH] =?UTF-8?q?test(toolcall):=20=E8=A1=A5=E6=89=B9=E5=86=85?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=9A=84=E4=B8=89=E6=9D=A1=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=EF=BC=88=E9=98=B6=E6=AE=B5=200.5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一批多个 tool_call 的循环(StepToolBegin→Exec→After)此前只被 scheduler_critical_test.go:125 一条用例覆盖「按序执行」,缺的三条正是 阶段 2(并行执行层)要改的地方: · tool_call_id 配对完整性 —— 阶段 2 改消息落法(一个 assistant 带全部 tool_calls + N 条 tool)时,配对断裂上游会直接报错 · ContentOnce 批内语义 —— 同一段 assistant 文本在批内重复 N 次,撑爆上下文 · denied 后继续批内 —— 改成 abort 会丢掉本可执行的后续调用 判据 toolbatch_test.go(4 条),全部确定性断言:阶段 0 已消除 map 迭代随机性,同批工具的落序与配对可稳定断言。 变异验证:令 stepToolBegin 跳过批内最后一个工具后,4 条判据同时 FAIL (既有那条也 FAIL),报错直指 ToolsUsed=[tool_alpha]、 tool_call_id "c2" 被声明 0 次。 更正一处此前的不准确表述:我曾说「无任何测试直接驱动批内路径」—— 不准确。scheduler_critical_test.go:125 已驱动「同批两工具按序执行」; 漏查是因为只 grep 了 PendingTools/ToolIdx 字段名,没查断言内容。 真正缺的是上表三条。 过程中三次自伤(均由「判据先写」暴露):臆造不存在的 helper; stageHost 置 nil 后又使用;给 newTaskFrame 传 nil 导致 stepPrepare 于 task.go:519 nil 解引用 panic(改用仓内既有 a.stageCtxFromInput)。 顺带记录:生产两处 newTaskFrame 调用都传真实 ctx,但 stepPrepare 对 f.StageCtx 无 nil 兜底——本次不修(无生产触发路径),记为潜在缺口。 回归:internal/agent/... 与 internal/plugins/... 全绿。 --- docs/zh/toolcall-parallel-execution-plan.md | 41 ++-- internal/agent/core/toolbatch_test.go | 247 ++++++++++++++++++++ 2 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 internal/agent/core/toolbatch_test.go diff --git a/docs/zh/toolcall-parallel-execution-plan.md b/docs/zh/toolcall-parallel-execution-plan.md index 3f1acb1..63b9148 100644 --- a/docs/zh/toolcall-parallel-execution-plan.md +++ b/docs/zh/toolcall-parallel-execution-plan.md @@ -15,7 +15,7 @@ | --- | --- | --- | --- | | **0** | 修 map 迭代顺序(flush 乱序) | — | ✅ **已完成** | | **0.2** | **工具错误类型化(已完成)** | — | ✅ **已完成** | -| **0.5** | 补多 tool_call 批内路径判据 | — | ⬜ 待办 | +| **0.5** | 补多 tool_call 批内路径判据 | — | ✅ **已完成** | | **1** | 结果契约 + 参数预校验 | — | ⬜ 待办 | | **2** | 并行执行层 | 1 | ⬜ 待办 | | **2.5** | 提示词改为「默认并行」 | 2 | ⬜ 待办 | @@ -78,24 +78,37 @@ FAIL(`父的执行失败被误报为『工具不存在』`);修复后全 --- -## 阶段 0.5 ⬜ 补批内路径判据 +## 阶段 0.5 ✅ 补批内路径判据(已完成) -**为什么先做**:核实到**仓内没有任何测试直接驱动 `PendingTools` / `ToolIdx`**, -即「同一批多个 tool_call」这条路径**无判据可依**。阶段 2 要改的正是这段逻辑。 +**前提更正**(此前我写「无任何测试直接驱动批内路径」——**不准确**): +`scheduler_critical_test.go:125` 的 `TestBatch_NotAbandonedWithoutPreemption` +**已经**驱动了「同批两个工具按序执行」。漏查是因为我只 grep 了 +`PendingTools` / `ToolIdx` 这两个**字段名**,没查断言**内容**。 -**产出**:`internal/agent/core/toolbatch_test.go`(新建) +**真正缺的是**该测试**未覆盖**的三条(已核实全仓无对应断言): -1. **批内全序列可观测**:构造一个假 provider,一轮返回 2 个 tool_call, - 断言 `f.ToolsUsed` 含两个、`f.ToolResults` 有两条、`f.Msgs` 里 tool_call_id 配对完整。 -2. **配对完整性**:断言每个 tool_call_id 都有且仅有一条 `role=tool` 消息。 - (这条是并行化的安全网——一旦落法改成「一个 assistant 带全部 tool_calls」, - 它能立刻发现配对被破坏。) -3. **`content_once` 语义**:`f.ContentOnce` 保证 assistant 文本只出现一次, - 在批内多工具下必须仍然成立。 +| 缺口 | 风险 | 新增判据 | +| --- | --- | --- | +| `tool_call_id` 配对完整性 | 阶段 2 改消息落法时,配对一旦断裂上游直接报错 | `TestBatchToolCallIDsAllPaired` | +| `ContentOnce` 批内语义 | 同一段 assistant 文本在批内重复 N 次,撑爆上下文 | `TestBatchAssistantTextAppearsOnce` | +| denied 后**继续**批内 | 改成 abort 会丢掉本可执行的后续调用 | `TestBatchContinuesAfterDeniedTool` | -**判据形态**:全部为**确定性**断言,不依赖 map 随机性(阶段 0 已把乱序消除)。 +另加 `TestBatchExecutesEveryToolCall`(批内全序列 + 顺序 + ToolResults 完整性)。 ---- +**判据**:`toolbatch_test.go`(4 条)+ 复用既有 `TestBatch_NotAbandonedWithoutPreemption`。 +全部**确定性**断言(阶段 0 已消除 map 随机性)。 + +**变异验证**:令 `stepToolBegin` 跳过批内最后一个工具后,**4 条判据同时 FAIL** +(既有那条也 FAIL),报错直指 `ToolsUsed = [tool_alpha]`、`tool_call_id "c2" 被声明 0 次`。 + +⚠️ **过程中三次自伤**(都靠"判据先写"暴露): +1. 臆造了不存在的 helper(`sdkToolDef` / `sdkStageCtx`)⇒ build 失败 +2. 把 `a.stageHost = nil` 后又用它注册 stage handler +3. 给 `newTaskFrame` 传 `nil` ⇒ `stepPrepare` 于 `task.go:519` **nil 解引用 panic** + ⇒ 改用仓内既有的 `a.stageCtxFromInput(...)`(与 `scheduler_preempt_test` 一致) + ★ 顺带记录:生产路径两处 `newTaskFrame` 调用(`task.go:228/422`)都传真实 ctx, + 但 `stepPrepare` 对 `f.StageCtx` **无 nil 兜底**。本次不修(无生产触发路径), + 记为潜在健壮性缺口。 ## 阶段 1 ⬜ 结果契约 + 参数预校验 diff --git a/internal/agent/core/toolbatch_test.go b/internal/agent/core/toolbatch_test.go new file mode 100644 index 0000000..2997da9 --- /dev/null +++ b/internal/agent/core/toolbatch_test.go @@ -0,0 +1,247 @@ +package core + +import ( + "context" + "sync" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// 本文件钉死「同一批多个 tool_call」这条路径的现状行为。 +// +// 为何必须先有它(已核实):仓内此前**没有任何测试直接驱动** +// PendingTools / ToolIdx —— 即批内循环(StepToolBegin → Exec → After → +// StepToolBegin…)**无判据可依**。而阶段 2(并行执行层)要改的正是这段。 +// 没有判据就改,等于在无保护的核心路径上动手。 +// +// 这些断言全部是**确定性**的:阶段 0 已消除 map 迭代随机性, +// 同一批工具的落序与消息配对可稳定断言。 + +// batchProvider 依次返回预设响应;ChatStream 不支持流式(驱动回退到 Chat)。 +type batchProvider struct { + mu sync.Mutex + responses []*agentAPI.CompletionResponse + calls int +} + +func (p *batchProvider) Name() string { return "batch" } + +func (p *batchProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.calls++ + if p.calls-1 < len(p.responses) { + return p.responses[p.calls-1], nil + } + return &agentAPI.CompletionResponse{Content: "done"}, nil +} + +func (p *batchProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + return nil, context.Canceled +} +func (p *batchProvider) MaxContextTokens() int { return 8192 } + +// newBatchAgent 建一个带两枚「记录型」工具的 agent。 +// 返回的 *[]string 按调用顺序记录 tool 名,便于断言批内顺序。 +func newBatchAgent(t *testing.T, sp agentAPI.Provider) (*Agent, *[]string) { + t.Helper() + a := New(AgentConfig{ + ID: "batchagent", + Provider: sp, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + }) + var mu sync.Mutex + var called []string + // 注意:阶段 2 起组内会**并发**执行,届时 toolFn 可能被多 goroutine 同时调用, + // 故 mu 必须一直持有(不能为“只在串行时用”而省略)。 + dev := &mockOutputDevice{ + name: "batchdev", + caps: agentIO.CapText, + tools: []agentIO.ToolDef{ + {Name: "tool_alpha", Description: "a"}, + {Name: "tool_beta", Description: "b"}, + }, + toolFn: func(tool string, args map[string]interface{}) (interface{}, error) { + mu.Lock() + called = append(called, tool) + mu.Unlock() + return "ran:" + tool, nil + }, + } + if err := a.io.RegisterDevice(dev); err != nil { + t.Fatalf("注册测试设备失败: %v", err) + } + return a, &called +} + +// 回归:同一批多个 tool_call 必须**全部**执行,且都进入 ToolResults。 +// +// 现状:StepToolBegin 用 f.ToolIdx 遍历 f.PendingTools,逐一执行。 +// 批内若有工具被漏掉(如索引推进错误),本判据立即失败。 +func TestBatchExecutesEveryToolCall(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: "batch text", ToolCalls: []agentAPI.ToolCall{tcA, tcB}}, + {Content: "final"}, + }} + a, called := newBatchAgent(t, sp) + + resp, toolsUsed, results, err := a.process("go", a.stageCtxFromInput("go", "", "")) + if err != nil { + t.Fatalf("process: %v", err) + } + if resp != "final" { + t.Errorf("最终响应 = %q,期望 %q", resp, "final") + } + if len(toolsUsed) != 2 || toolsUsed[0] != "tool_alpha" || toolsUsed[1] != "tool_beta" { + t.Errorf("ToolsUsed = %v,期望 [tool_alpha tool_beta]", toolsUsed) + } + if len(*called) != 2 { + t.Fatalf("实际执行 %v,期望两个都执行", *called) + } + // 批内顺序必须等于模型给出的顺序(阶段 0 修复的落序问题在批内的体现)。 + if (*called)[0] != "tool_alpha" || (*called)[1] != "tool_beta" { + t.Errorf("批内执行顺序 = %v,期望 [tool_alpha tool_beta]", *called) + } + if len(results) != 2 { + t.Fatalf("ToolResults 有 %d 条,期望 2", len(results)) + } + for _, r := range results { + if r.Output == "" { + t.Errorf("工具 %s 的结果为空", r.Name) + } + } +} + +// 回归:批内每个 tool_call_id 都必须有且仅有一条 role=tool 消息配对。 +// +// 这是并行化(阶段 2 把落法改成「一个 assistant 带全部 tool_calls + N 条 tool」) +// 的**安全网**:配对一旦断裂,上游会因 tool_call_id 找不到结果而报错。 +// 本判据只看配对完整性,不断言消息的物理排列(那正是 2a 要改的部分)。 +func TestBatchToolCallIDsAllPaired(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) + + // 直接驱动状态机并保留帧,以便检查 msgs。 + f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", "")) + if out := a.runTaskSteps(f); out != outcomeDone { + t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err) + } + + // 收集 assistant 声明的 tool_call_id 与 tool 消息回填的 id。 + declared := map[string]int{} + answered := map[string]int{} + for _, m := range f.Msgs { + for _, tc := range m.ToolCalls { + declared[tc.ID]++ + } + if m.Role == "tool" { + answered[m.ToolCallID]++ + } + } + for _, id := range []string{"c1", "c2"} { + if declared[id] != 1 { + t.Errorf("tool_call_id %q 被声明 %d 次,期望 1 次", id, declared[id]) + } + if answered[id] != 1 { + t.Errorf("tool_call_id %q 被回填 %d 次,期望 1 次", id, answered[id]) + } + } + // 不得有悬空的 tool 消息。 + for id, n := range answered { + if declared[id] == 0 { + t.Errorf("存在无对应 tool_call 声明的 tool 消息: id=%q n=%d", id, n) + } + } +} + +// 回归:批内 assistant 的文本只应出现**一次**(ContentOnce 语义)。 +// +// 现状:f.ContentOnce 保证 f.Resp.Content 只挂在第一个工具的 assistant 消息上, +// 避免同一段文本在批内被重复 N 次、撑爆上下文。 +func TestBatchAssistantTextAppearsOnce(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: "UNIQUE_BATCH_TEXT", 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) + } + n := 0 + for _, m := range f.Msgs { + if m.Role == "assistant" && m.Content == "UNIQUE_BATCH_TEXT" { + n++ + } + } + if n != 1 { + t.Errorf("批内 assistant 文本出现 %d 次,期望恰好 1 次(ContentOnce 语义)", n) + } +} + +// 回归:批内某个工具**被拒绝**时,批内其余工具仍应继续执行。 +// +// 现状:stepToolBegin 的 denied 分支只 f.ToolIdx++ 并 continue,不中断整批。 +// 若改成 abort,模型会丢掉本可执行的后续调用。 +func TestBatchContinuesAfterDeniedTool(t *testing.T) { + reason := "策略拒绝" + sp := &batchProvider{responses: []*agentAPI.CompletionResponse{ + {ToolCalls: []agentAPI.ToolCall{ + {ID: "c1", Name: "tool_alpha", Arguments: map[string]interface{}{}}, + {ID: "c2", Name: "tool_beta", Arguments: map[string]interface{}{}}, + }}, + {Content: "final"}, + }} + a, _ := newBatchAgent(t, sp) + + // before_toolcall 只拒绝 tool_alpha;tool_beta 放行。 + a.stageHost.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error { + if len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Name == "tool_alpha" { + r := reason + ctx.Response = &r + } + return nil + }) + + f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", "")) + if out := a.runTaskSteps(f); out != outcomeDone { + t.Fatalf("runTaskSteps = %v, err=%v", out, f.Err) + } + // 批内两个工具都要被「尝试」(拒绝也计入 ToolsUsed)。 + if len(f.ToolsUsed) != 2 { + t.Errorf("被拒后整批应继续,ToolsUsed = %v 期望两个", f.ToolsUsed) + } + // 拒绝理由必须回填给模型,且不阻断 tool_beta 的结果。 + var sawReason, sawBeta bool + for _, m := range f.Msgs { + if m.Role == "tool" { + if m.ToolCallID == "c1" && m.Content == reason { + sawReason = true + } + if m.ToolCallID == "c2" && m.Content != "" { + sawBeta = true + } + } + } + if !sawReason { + t.Errorf("拒绝理由未回填给模型") + } + if !sawBeta { + t.Errorf("tool_beta 的结果未回填(被前一工具的拒绝连带丢弃)") + } +}