refactor(scheduler): M1 把 process() 拆成 step 状态机 + TaskFrame(行为等价)

设计依据 docs/zh/input-scheduler-design.md §14 M1。

- 新增 task.go:Step 游标、TaskFrame,以及 stepPrepare/stepLLM/
  stepToolBegin/stepToolExec/stepToolAfter/stepTurnEnd 六个 step;
  原函数内嵌的 provider 回退/重试抽为 resolveProviders + callLLMWithFallback
- process() 改为驱动状态机的薄壳:签名不变,调用方(processInput/
  processConsolidation/测试)零改动
- StepToolExec 显式标注为临界区:工具副作用不可回滚,执行中不是安全点
- 步数上限护栏:转移缺失时以错误退出而非死循环
- 新增 task_test.go:R3(工具往返结果与配对正确)、X3(多轮必然终止)、
  批中途中断必须放弃剩余工具、未知 step 必须失败退出
- 验收:既有 agent 全量测试通过;-race 通过(TMPDIR 指向真实磁盘,
  /tmp tmpfs 已 98% 满会导致链接失败,与代码无关)
This commit is contained in:
JianFeeeee
2026-09-12 23:47:12 +08:00
parent 06edff1af2
commit 9a588780b9
3 changed files with 822 additions and 416 deletions

View File

@ -0,0 +1,209 @@
package core
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
//
// 设计依据 docs/zh/input-scheduler-design.md §11R3 / X3 的 M1 形态):
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
//
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
import (
"context"
"errors"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// scriptProvider 按脚本依次返回 CompletionResponse。
//
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
type scriptProvider struct {
script []*agentAPI.CompletionResponse
idx int
reqs []*agentAPI.CompletionRequest
}
func (s *scriptProvider) Name() string { return "script" }
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
s.reqs = append(s.reqs, req)
if s.idx >= len(s.script) {
return &agentAPI.CompletionResponse{Content: ""}, nil
}
r := s.script[s.idx]
s.idx++
return r, nil
}
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("script provider: streaming disabled")
}
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
t.Helper()
return New(AgentConfig{
ID: "task-test",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
StageHost: sh,
IO: agentIO.NewIOManager(),
})
}
func tc(id, name string) agentAPI.ToolCall {
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
}
// R3M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
sh := NewStageHost()
var got []string
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
got = append(got, args["q"].(string))
return "OUT", nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
{Content: "最终答复"},
}}
a := newTaskTestAgent(t, sp, sh)
stageCtx := a.stageCtxFromInput("你好", "", "")
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "最终答复" {
t.Fatalf("响应=%q期望 %q", resp, "最终答复")
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
t.Fatalf("toolsUsed=%v期望恰好一次 t_echo", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
t.Fatalf("toolResults=%+v期望一条 t_echo/OUT", toolResults)
}
if len(got) != 1 || got[0] != "c1" {
t.Fatalf("工具实参=%v期望恰好执行一次且参数来自脚本", got)
}
if len(sp.reqs) != 2 {
t.Fatalf("LLM 调用次数=%d期望 2工具轮 + 收尾轮)", len(sp.reqs))
}
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
msgs := sp.reqs[1].Messages
var hasAssistantCall, hasToolResult bool
for _, m := range msgs {
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
hasAssistantCall = true
}
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
hasToolResult = true
}
}
if !hasAssistantCall || !hasToolResult {
t.Fatalf("第二轮请求缺少工具调用配对assistant=%v tool=%v", hasAssistantCall, hasToolResult)
}
}
// X3M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
sh := NewStageHost()
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
var script []*agentAPI.CompletionResponse
for i := 0; i < 3; i++ {
script = append(script, &agentAPI.CompletionResponse{
Content: "round",
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
})
}
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
sp := &scriptProvider{script: script}
a := newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "done" {
t.Fatalf("响应=%q期望 done", resp)
}
if len(toolsUsed) != 3 || len(toolResults) != 3 {
t.Fatalf("toolsUsed=%d toolResults=%d期望各 3", len(toolsUsed), len(toolResults))
}
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
if len(sp.reqs) != 4 {
t.Fatalf("LLM 调用次数=%d期望 4", len(sp.reqs))
}
}
// 中断interceptCh在工具批中途到达时本批**剩余工具被放弃**,直接进入下一轮。
//
// 这是原实现的 `break` 语义process.go 旧版工具循环尾部),必须保持。
func TestTaskFrame_InterruptAbandonsRemainingBatch(t *testing.T) {
sh := NewStageHost()
var a *Agent
sh.RegisterTool("t_first", sdk.ToolDef{Name: "t_first", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
// 工具执行期间产生一次中断(模拟插件在工具里注入打断)。
a.interceptCh <- &agentIO.InputEvent{Source: "t", Payload: map[string]interface{}{"content": "新的用户输入"}}
return "first-out", nil
})
sh.RegisterTool("t_second", sdk.ToolDef{Name: "t_second", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
t.Fatal("批内第二个工具不应被执行:中断必须放弃剩余批次")
return nil, nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_first"), tc("c2", "t_second")}},
{Content: "处理完中断后的答复"},
}}
a = newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("开始", a.stageCtxFromInput("开始", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "处理完中断后的答复" {
t.Fatalf("响应=%q", resp)
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_first" {
t.Fatalf("toolsUsed=%v期望只有 t_first", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Output != "first-out" {
t.Fatalf("toolResults=%+v期望只有 t_first 的结果", toolResults)
}
// 第二轮请求里必须出现被打断内容system 角色、[中断消息] 前缀)。
found := false
for _, m := range sp.reqs[1].Messages {
if m.Role == "system" && len(m.Content) > 0 && m.Content[0:len("[中断消息]")] == "[中断消息]" {
found = true
}
}
if !found {
t.Fatalf("第二轮请求缺少 [中断消息] system 消息")
}
}
// 状态机对未知 step 必须失败退出而不是空转。
func TestTaskFrame_UnknownStepFails(t *testing.T) {
sp := &scriptProvider{}
a := newTaskTestAgent(t, sp, NewStageHost())
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
f.Step = Step(999)
if out := a.step(f); out != outcomeFailed {
t.Fatalf("未知 step 应返回 outcomeFailed实际 %v", out)
}
if f.Err == nil {
t.Fatal("未知 step 必须带错误信息")
}
}