mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
同一批多个 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/... 全绿。
248 lines
8.8 KiB
Go
248 lines
8.8 KiB
Go
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 的结果未回填(被前一工具的拒绝连带丢弃)")
|
||
}
|
||
}
|