执行方案阶段 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 旋钮管道)。
333 lines
12 KiB
Go
333 lines
12 KiB
Go
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)
|
||
}
|
||
}
|