Files
HomeAgent/internal/agent/core/toolbatch_test.go
JianFeeeee 4ba72977d2 test(toolcall): 补批内路径的三条缺失判据(阶段 0.5)
同一批多个 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/... 全绿。
2026-09-27 08:59:30 +08:00

248 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 的结果未回填(被前一工具的拒绝连带丢弃)")
}
}