refactor(toolcall): StageContext 拆 per-tool,为并发执行消除共享槽(阶段 2c)

问题:f.StageCtx 是**单槽**,批内每个工具都覆写它(ToolCalls=[单元素]、
ToolResults 覆写、Results[0] 回读)。串行下看不出问题,但并发下
N 个 goroutine 同写一个 ctx = 数据竞争,且 after_toolcall 插件可能读到
**别的工具**的结果。

改动(task.go):
· TaskFrame 增 toolCtxs []sdk.StageContext(每工具一份)
· buildToolContexts 在 stepLLM 设 PendingTools 时建池;
  Extra **逐份浅拷贝**——共享同一 map 即竞争(stage handler 会写它)
· toolCtxFor(i) 取第 i 份,越界/未建时回落 f.StageCtx(测试替身安全)
· stepToolBegin(before_toolcall + 参数回填)、stepToolExec(写结果)、
  stepToolAfter(读结果)三处全部切到 per-tool ctx

判据(toolbatch_test.go 追加两条):
· 每个工具的 before_toolcall ctx 只带自己的 ToolCalls[0].Name,
  且 Extra[output_channel] 逐份带过去(stage.go:18 依赖它)
· after_toolcall 读到的 Result 必须属于当前工具,不能是批内另一个的

★ 诚实记录:这两条判据在**串行**下**测不出与单槽的差别**——串行时
每工具跑完才进下一个,不存在交错。变体验证(toolCtxFor 退回单槽)后
判据仍全绿。故 2c 记为「实现已就位、判据未闭合」,真正判据必须与 2d
(并发执行)一起写,并以 -race 确认无竞争。已在执行计划中标注。

过程中两次自伤:
· 我的 harness 没设 Extra[output_channel](那是 prepareInputTask 才写的,
  task.go:380),判据一度报「产品缺陷」——核实后是我造的场景,已对齐生产;
· 阶段 1 的 TestStageCtxSuccessIsHonestEndToEnd 读 f.StageCtx.ToolResults,
  拆分后失效——判据跟随新结构改为按批索引取 toolCtxs[i],
  断言的仍是内核产出的 Success 值本身。

顺带记录(非本次引入):TestResidualKeep/Drop 偶发失败,根因是
offload_test.go 的 SpawnResident 起了子调度器 goroutine,而测试
enqueue 后无同步就读同一队列。干净基线 3/3 全绿属运气。已在计划中
记为待修,避免后续误判为并行化引入的回归。
This commit is contained in:
JianFeeeee
2026-09-27 11:17:06 +08:00
parent 28b42bcf0f
commit 3d12e82f65
4 changed files with 234 additions and 58 deletions

View File

@ -2,6 +2,8 @@ package core
import (
"context"
"fmt"
"strings"
"sync"
"testing"
@ -299,3 +301,104 @@ func TestBatchLayoutSingleAssistantCarriesAllToolCalls(t *testing.T) {
t.Errorf("tool 消息应按 index 升序,实际 %v", gotIDs)
}
}
// 阶段 2c:`StageContext` 拆 per-tool。
//
// 现状:f.StageCtx 是**单槽**,批内每个工具都覆写它
// (ToolCalls=[单元素]、ToolResults 覆写、Results[0] 回读)。并行下
// N 个 goroutine 同写一个 ctx = 数据竞争,且 after_toolcall 插件读到的
// 可能是**别的工具**的结果。
//
// 本判据钉死:每个工具的 before/after stage 必须各自看到**自己的**
// ToolCalls[0].Name 与自己的结果,输出通道等 Extra 也要逐份带过去。
func TestBatchEachToolSeesItsOwnStageContext(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)
var mu sync.Mutex
beforeSeen := map[string]string{}
var missingChannel int
a.stageHost.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
name := ""
if len(ctx.ToolCalls) > 0 {
name = ctx.ToolCalls[0].Name
}
// 每个工具的 ctx 必须只带它自己(长度恒为 1),否则就是单槽串味。
if len(ctx.ToolCalls) != 1 {
t.Errorf("before_toolcall 的 ctx 应只带 1 个 ToolCall,实际 %d", len(ctx.ToolCalls))
}
// Extra 里的 output_channel 必须逐份复制过来(stage.go:18 依赖它)。
if _, ok := ctx.Extra["output_channel"]; !ok {
mu.Lock()
missingChannel++
mu.Unlock()
}
mu.Lock()
beforeSeen[name] = name
mu.Unlock()
return nil
})
// 复刻生产:prepareInputTask 会往 Extra 写 input_source/output_channel
//(task.go:380-381)。我的 harness 若不设它,测的就是「Extra 缺失」
// 这一**自己造的**场景,而不是「per-tool 复制」——先修正 harness。
ctx := a.stageCtxFromInput("go", "cli", "")
ctx.Extra["output_channel"] = "cli"
ctx.Extra["input_source"] = "cli"
if out := a.runTaskSteps(a.newTaskFrame("go", ctx)); out != outcomeDone {
t.Fatalf("runTaskSteps 未收敛: %v", out)
}
if len(beforeSeen) != 2 {
t.Errorf("before_toolcall 应被两个工具各触发一次且名字不同,实际 %v", beforeSeen)
}
for _, want := range []string{"tool_alpha", "tool_beta"} {
if beforeSeen[want] != want {
t.Errorf("工具 %s 的 before_toolcall 未看到自己(看到 %q)", want, beforeSeen[want])
}
}
if missingChannel > 0 {
t.Errorf("有 %d 个工具的 ctx 缺少 Extra[output_channel]", missingChannel)
}
}
// 反向断言:after_toolcall 读到的结果必须属于**当前**工具,
// 不能是批内另一个工具的(单槽下极易串味)。
func TestBatchAfterToolcallSeesOwnResult(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)
var mu sync.Mutex
bad := map[string]string{}
a.stageHost.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
if len(ctx.ToolResults) == 0 {
return nil
}
name := ctx.ToolResults[0].Name
res := fmt.Sprint(ctx.ToolResults[0].Result)
// 结果文案必须含自己的工具名("ran:tool_alpha"),否则就是串味。
if !strings.Contains(res, name) {
mu.Lock()
bad[name] = res
mu.Unlock()
}
return nil
})
if out := a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))); out != outcomeDone {
t.Fatalf("runTaskSteps 未收敛: %v", out)
}
if len(bad) > 0 {
t.Errorf("after_toolcall 读到别的工具的结果: %v", bad)
}
}