Files
Cyrene_v2/internal/runtime/orchestration/spawntask_test.go
T
AskaEth 7e7f53bb41
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
执行方案阶段 2 · P2-1。**02.4.2 是最伤的一条 P0**:`spawntool` 从不填 `AgentSpec.Context`、
`scheduler` 把 nil 交给 loop ⇒ 子代理模型上下文 = 角色 system + 记忆召回 + **0 条消息**,
goal/attach_info/data 一个字都到不了模型(此前测试用 mock adapter 恒答固定文本,测不出)。

- **02.4.2**:新增 `taskContext/taskUserMessage`(`spec.go`)把 **goal 正文 + attach_info + data
  渲染成同一条 user 消息**(`[任务目标]/[附加信息]/[任务数据]` 独立段落);`Spawn()` 设
  `Context: taskContext(spec)`;**system 只保留角色指令**,任务绝不进 system。
  真断言(捕获请求体的 adapter + 真 loop/agent):首请求恰好 **1 条 user 消息**、同时含 goal 与
  attach_info,且**所有 system 消息不含 goal 文本**。
- **02.4.1**:`AgentSpec.ToolWhitelist`(nil/空 = 继承全量);schema 增 `tool_whitelist`
  (顶层 + 每 task 条目,task 未带时继承批次级);`scheduler.run()` 经 ctx 传递 +
  `scopedRunner` 派生;`loop/options.go` **纯增量(+75 / −0)**:`WithToolWhitelist`/
  `ToolWhitelistFrom` + `(*Loop).WithToolWhitelist` 派生副本(浅拷贝仅替换 `hooks.CallAdmit`,
  链式保留原钩子),名单外调用否决为**软结果**回灌模型。
- **02.4.4**:`SUBAGENT_TIMEOUT_SEC`(默认 600)入 `internal/config` + `.env.example`;
  优先级 = 按调用 `timeout_seconds`(1s–24h 校验)> `WithSpawnTimeout`(生产由 cfg 填充)>
  调度器 `PerAgentTimeout`(`wire*.go` 仍是 10min 常量,未动);迟到 span 续根由测试锁定。

验证:**全树 `go build`/`go vet`/`go test ./... -count=1` = 58 包 ok / 0 FAIL**;
`GOOS=linux/amd64` + `GOOS=darwin/arm64` 交叉编译绿;前端 `tsc --noEmit` + `npm run build` 绿;
本批文件全部 ≤500 行、函数 ≤80 行。
变异验证(四条,均带还原证明):① `Context` 改回 `cloneMessages(spec.Context)` →
「1 条 user 消息」断言 FAIL(`user messages = 0`,即 P0 症状);② `WithToolWhitelist` 原样返回 →
显式白名单测试 FAIL(`alpha executed 1 times despite not being whitelisted`);
③ `subCtx` 改回全局默认 → 3 个超时测试 FAIL;④ `foldSpanFinish` 丢掉 `SpanFinished` →
迟到 span 状态断言 FAIL。

⚠️ 已知缺口(**登记,未假装已解决**):① 无真实模型端到端(全部捕获请求体 mock);
② 白名单**只挡执行、不过滤工具广告面**(模型仍能看见未列工具,调用即被拒并拿到可读原因;
过滤需改 `loop/request.go`);③ **迟到 span 的"新一轮已开始"窗口未修**(`loop/flow.go` 的
`StartRound` 会整体替换 `graphs[session]` ⇒ 新轮开始后迟到的 span 仍落进新轮图;需按轮键控图,
属后续);④ per-role 超时覆盖未实现(需 persona 旋钮管道)。
2026-10-04 11:30:39 +08:00

333 lines
12 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 orchestration
import (
"context"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// requestCaptureAdapter 记录每次模型请求的完整报文(Messages/Tools),
// 使测试能断言「子代理的模型真正看到了什么」——这是 02.4.2 的裁决
// (任务作为一条 user 消息注入)与 02.4.1(工具白名单)的真断言载体:
// 恒答固定文本的 mock 适配器测不出这两类缺陷。
type requestCaptureAdapter struct {
final string
mu sync.Mutex
requests []model.Request
}
func (a *requestCaptureAdapter) Name() string { return "capture" }
func (a *requestCaptureAdapter) record(req model.Request) {
a.mu.Lock()
a.requests = append(a.requests, req)
a.mu.Unlock()
}
func (a *requestCaptureAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
a.record(req)
return model.Response{Model: "mock-1", Content: a.final, StopReason: "end_turn"}, nil
}
func (a *requestCaptureAdapter) ChatStream(_ context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return a.Chat(context.Background(), req, model.Options{})
}
// request 返回第 i 次捕获的模型请求。
func (a *requestCaptureAdapter) request(i int) model.Request {
a.mu.Lock()
defer a.mu.Unlock()
return a.requests[i]
}
func (a *requestCaptureAdapter) count() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.requests)
}
// captureLoop 在真实 loop.Loop 之上构建带请求捕获的调度器(生产形状,
// 只是模型换成了捕获适配器)。tools 为 nil 时不接 dispatcher。
func captureLoop(t *testing.T, ad model.Adapter, d *tool.Dispatcher) *Scheduler {
t.Helper()
ag, err := agent.New("你是后台子代理,按角色与安全约束行事", d, nil,
agent.WithModel(ad, nil),
agent.WithOptions(agent.Options{Model: "mock-1"}))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
lp := loop.New(loop.Options{Agent: ag, Tools: d, MaxRounds: 8})
return newScheduler(t, Options{Loop: lp, MaxParallel: 1})
}
// messageTextOf 提取一条消息的纯文本(拼接 text 与 tool_result 分段,
// 后者承载被否决/失败调用的回灌文本)。
func messageTextOf(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText || c.Type == model.ContentTypeToolResult {
b.WriteString(c.Text)
}
}
return b.String()
}
// TestSpawnToolInjectsTaskAsUserMessage 是 02.4.2(P0「子代理收不到任务」)
// 的裁决断言:经生产派发工具创建的子代理,其模型请求里必须有一条 user
// 消息同时承载 goal 正文与 attach_info 独立段落;system 只放角色与安全
// 约束,绝不包含任务文本。
func TestSpawnToolInjectsTaskAsUserMessage(t *testing.T) {
t.Parallel()
ad := &requestCaptureAdapter{final: "子代理完成"}
s := captureLoop(t, ad, nil)
sp := NewSpawnTool(s, WithSpawnWait(3*time.Second))
raw, err := sp.Execute(context.Background(), map[string]any{
"goal": "调研量子纠错现状",
"attach_info": "只看 2026 年后的论文",
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "done" {
t.Fatalf("Status = %q, want done", got.Status)
}
if ad.count() == 0 {
t.Fatal("the sub-agent never reached a model; nothing to assert the task injection on")
}
msgs := ad.request(0).Messages
var userTexts, systemTexts []string
for _, m := range msgs {
switch m.Role {
case model.RoleUser:
userTexts = append(userTexts, messageTextOf(m))
case model.RoleSystem:
systemTexts = append(systemTexts, messageTextOf(m))
}
}
if len(userTexts) != 1 {
t.Fatalf("user messages = %d (%v), want exactly one carrying the task", len(userTexts), userTexts)
}
task := userTexts[0]
for _, want := range []string{"[任务目标]", "调研量子纠错现状", "[附加信息]", "只看 2026 年后的论文"} {
if !strings.Contains(task, want) {
t.Fatalf("task user message = %q, want it to contain %q", task, want)
}
}
for _, sys := range systemTexts {
if strings.Contains(sys, "调研量子纠错现状") {
t.Fatalf("system message carries the task text (%q); the ruling keeps the task out of system", sys)
}
}
}
// TestSpawnInjectsDataParagraph 断言 AgentSpec.Data 作为同一条 user 消息内的
// [任务数据] 段落到达模型(库路径调用方同样受裁决覆盖)。
func TestSpawnInjectsDataParagraph(t *testing.T) {
t.Parallel()
ad := &requestCaptureAdapter{final: "done"}
s := captureLoop(t, ad, nil)
sub, err := s.Spawn(context.Background(), AgentSpec{
Goal: "处理数据",
Data: map[string]any{"repo": "cyrene", "stars": 2},
})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
if _, werr := s.Wait(context.Background(), []string{sub.ID}); werr != nil {
t.Fatalf("Wait: %v", werr)
}
msgs := ad.request(0).Messages
var task string
users := 0
for _, m := range msgs {
if m.Role != model.RoleUser {
continue
}
users++
task = messageTextOf(m)
}
if users != 1 {
t.Fatalf("user messages = %d, want one", users)
}
for _, want := range []string{"[任务数据]", `"repo":"cyrene"`} {
if !strings.Contains(task, want) {
t.Fatalf("task message = %q, want it to contain %q", task, want)
}
}
}
// TestSpawnTimeoutFromToolDefault 断言 WithSpawnTimeout(生产经
// SUBAGENT_TIMEOUT_SEC 填充)覆盖调度器默认时限(02.4.4)。
func TestSpawnTimeoutFromToolDefault(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: time.Hour})
s.runner = &mockRunner{gate: make(chan struct{})} // 阻塞直到 ctx 超时
sp := NewSpawnTool(s, WithSpawnWait(30*time.Millisecond), WithSpawnTimeout(120*time.Millisecond))
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "slow"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
got := decodeSpawnResult(t, raw)
if got.Status != "running" {
t.Fatalf("Status = %q, want running", got.Status)
}
id := got.Agents[0].ID
waitFor(t, func() bool { return statusOf(s, id) == StatusAborted }, waitTimeout)
sub := s.pickOrFail(t, id)
if !strings.Contains(sub.Summary, "120ms") {
t.Fatalf("Summary = %q, want the 120ms tool-default deadline named", sub.Summary)
}
}
// TestSpawnTimeoutPerCallOverride 断言按调用覆盖(timeout_seconds)优先于
// 派发默认与调度器默认(design/05 §7 裁决 02.4.4)。
func TestSpawnTimeoutPerCallOverride(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: time.Hour})
s.runner = &mockRunner{gate: make(chan struct{})}
sp := NewSpawnTool(s, WithSpawnWait(30*time.Millisecond), WithSpawnTimeout(time.Hour))
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "slow", "timeout_seconds": float64(1)})
if err != nil {
t.Fatalf("Execute: %v", err)
}
id := decodeSpawnResult(t, raw).Agents[0].ID
waitFor(t, func() bool { return statusOf(s, id) == StatusAborted }, 3*time.Second)
sub := s.pickOrFail(t, id)
if !strings.Contains(sub.Summary, "1s") {
t.Fatalf("Summary = %q, want the 1s per-call deadline named", sub.Summary)
}
}
// TestSpawnTimeoutSpecOverride 是库路径的按调用覆盖断言:AgentSpec.Timeout
// 优先于 Options.PerAgentTimeout。
func TestSpawnTimeoutSpecOverride(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: time.Hour})
s.runner = &mockRunner{gate: make(chan struct{})}
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "quick", Timeout: 80 * time.Millisecond})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, func() bool { return sub.Status() == StatusAborted }, waitTimeout)
if !strings.Contains(sub.Summary(), "80ms") {
t.Fatalf("Summary = %q, want the 80ms spec deadline named", sub.Summary())
}
if sub.FinishedAt().Sub(sub.StartedAt()) > 5*time.Second {
t.Fatalf("run took %v; the per-call override never applied", sub.FinishedAt().Sub(sub.StartedAt()))
}
}
// pickOrFail 按 id 取子代理快照,找不到时失败测试。
func (s *Scheduler) pickOrFail(t *testing.T, id string) SubAgentResult {
t.Helper()
for _, a := range s.Agents() {
if a.ID == id {
return a
}
}
t.Fatalf("sub-agent %q not registered", id)
return SubAgentResult{}
}
// TestLateSubagentSpanRejoinsOriginalRound 是 02.4.4「迟到 span 续根」的
// 归属断言(design/05 §9、design/19 §2):回合(EndRound)先于子代理结束,
// 之后才到达的子代理 span 必须归入原轮(web-1)的泳道——根节点翻转为终态、
// 内部步骤链完整——而不是被丢弃,也不是另起新轮根。
func TestLateSubagentSpanRejoinsOriginalRound(t *testing.T) {
t.Parallel()
capture := &graphCapture{}
flow := loop.NewFlowPublisher(capture.publish, nil)
tracer := trace.New(nil)
d, err := tool.New(registry.New())
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if rerr := d.Register(&echoTool{name: "echo", result: "ok", delay: 150 * time.Millisecond}); rerr != nil {
t.Fatalf("Register: %v", rerr)
}
s := branchLoop(t, flow, tracer, d, Options{MaxParallel: 1})
flow.StartRound(parentSession, "帮我调研")
sub, serr := s.Spawn(context.Background(), AgentSpec{Goal: "慢速调研", ParentSession: parentSession})
if serr != nil {
t.Fatalf("Spawn: %v", serr)
}
// 回合在子代理仍在运行时就结束:其后到达的 span 都是「迟到 span」。
flow.EndRound(parentSession, "结果如下")
if _, werr := s.Wait(context.Background(), []string{sub.ID}); werr != nil {
t.Fatalf("Wait: %v", werr)
}
// 不丢、不另起根:整个会话只出版过一轮(web-1),迟到的泳道落在它里面。
if ids := capture.ids(parentSession); len(ids) != 1 || ids[0] != parentSession+"-1" {
t.Fatalf("published rounds for %q = %v, want exactly the original round %s-1", parentSession, ids, parentSession)
}
if capture.has(sub.ID) {
t.Fatalf("the late sub-agent opened a graph of its own (%s); it must rejoin the original round", sub.ID)
}
g := capture.graph(t, parentSession)
if g.ID != parentSession+"-1" {
t.Fatalf("graph id = %q, want the original round's %s-1", g.ID, parentSession)
}
// 原轮的收尾仍在,且迟到泳道完整挂靠:根节点终态 + llm>tool>llm 步骤链。
if flowNodeByID(t, g, "output").Kind != "output" {
t.Fatal("the closed round lost its output node")
}
subNode := flowSubagentNode(t, g, sub.ID)
if subNode.Status != trace.FlowStatusDone {
t.Fatalf("late subagent node status = %q, want done (the late finish must flip its root)", subNode.Status)
}
if chain := flowLaneChain(t, g, subNode); len(chain) != 3 || chainKinds(chain) != "llm>tool>llm" {
t.Fatalf("late lane chain = %s, want llm>tool>llm inside the original round", chainKinds(chain))
}
}
// TestTaskContextIsolationFromCallerMutation 锁定注入消息与调用方切片的隔离:
// Spawn 后修改 spec.Context / spec.ToolWhitelist 不得影响已创建的子代理。
func TestTaskContextIsolationFromCallerMutation(t *testing.T) {
t.Parallel()
s := newScheduler(t, Options{MaxParallel: 1})
m := &ctxCaptureRunner{}
s.runner = m
wl := []string{"alpha"}
spec := AgentSpec{Goal: "g", ToolWhitelist: wl, Context: []model.Message{model.TextMessage(model.RoleUser, "seed")}}
sub, err := s.Spawn(context.Background(), spec)
if err != nil {
t.Fatalf("Spawn: %v", err)
}
wl[0] = "mutated"
spec.Context[0].Content[0].Text = "mutated"
if _, werr := s.Wait(context.Background(), []string{sub.ID}); werr != nil {
t.Fatalf("Wait: %v", werr)
}
if got := loop.ToolWhitelistFrom(m.last()); len(got) != 1 || got[0] != "alpha" {
t.Fatalf("whitelist = %v, want the snapshot [alpha] immune to caller mutation", got)
}
if sub.ToolWhitelist[0] != "alpha" {
t.Fatalf("sub-agent whitelist = %v, want [alpha]", sub.ToolWhitelist)
}
if sub.Context[0].Content[0].Text != "seed" {
t.Fatalf("sub-agent context = %q, want the isolated seed", sub.Context[0].Content[0].Text)
}
}