执行方案阶段 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 旋钮管道)。
258 lines
7.7 KiB
Go
258 lines
7.7 KiB
Go
package orchestration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/orchestration/comm"
|
|
)
|
|
|
|
func TestJoinMergesResults(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{MaxParallel: 2})
|
|
m := &mockRunner{result: &loop.Result{Text: "answer", Steps: 3, StopReason: "stop"}}
|
|
s.runner = m
|
|
|
|
ctx := context.Background()
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := s.Spawn(ctx, AgentSpec{Goal: fmt.Sprintf("goal %d", i)}); err != nil {
|
|
t.Fatalf("Spawn %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
res, err := s.Join(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Join: %v", err)
|
|
}
|
|
if res.Total != 3 || res.Succeeded != 3 || res.Failed != 0 || res.Aborted != 0 {
|
|
t.Fatalf("merged = %d total / %d ok / %d failed / %d aborted, want 3/3/0/0",
|
|
res.Total, res.Succeeded, res.Failed, res.Aborted)
|
|
}
|
|
if len(res.Agents) != 3 {
|
|
t.Fatalf("Agents len = %d, want 3", len(res.Agents))
|
|
}
|
|
for _, a := range res.Agents {
|
|
if a.Status != StatusDone {
|
|
t.Fatalf("agent %s status = %s, want done", a.ID, a.Status)
|
|
}
|
|
if a.Summary == "" {
|
|
t.Fatalf("agent %s has no summary", a.ID)
|
|
}
|
|
if a.Text != "answer" || a.Steps != 3 {
|
|
t.Fatalf("agent %s result = text %q steps %d, want answer/3", a.ID, a.Text, a.Steps)
|
|
}
|
|
}
|
|
if !strings.Contains(res.Summary, "3 成功") {
|
|
t.Fatalf("merged summary = %q, want a 3-成功 aggregate", res.Summary)
|
|
}
|
|
}
|
|
|
|
func TestFailureSummary(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{MaxParallel: 2})
|
|
boom := errors.New("boom")
|
|
m := &mockRunner{err: boom}
|
|
s.runner = m
|
|
|
|
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"})
|
|
if err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
waitFor(t, func() bool { return sub.Status() == StatusFailed }, waitTimeout)
|
|
if !strings.Contains(sub.Summary(), "boom") {
|
|
t.Fatalf("Summary = %q, want the failure recorded", sub.Summary())
|
|
}
|
|
if !errors.Is(sub.Err(), boom) {
|
|
t.Fatalf("Err = %v, want boom", sub.Err())
|
|
}
|
|
if sub.Result() != nil {
|
|
t.Fatalf("Result = %v, want nil on failure", sub.Result())
|
|
}
|
|
|
|
res, err := s.Join(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Join: %v", err)
|
|
}
|
|
if res.Total != 1 || res.Failed != 1 || res.Succeeded != 0 {
|
|
t.Fatalf("merged = %d total / %d ok / %d failed, want 1/0/1",
|
|
res.Total, res.Succeeded, res.Failed)
|
|
}
|
|
}
|
|
|
|
func TestContextIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{MaxParallel: 1})
|
|
m := &mockRunner{result: &loop.Result{Text: "done"}}
|
|
s.runner = m
|
|
|
|
specCtx := []model.Message{model.TextMessage(model.RoleUser, "hello")}
|
|
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x", Context: specCtx})
|
|
if err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
|
|
// Mutating the caller's slice after Spawn must not leak into the sub-agent.
|
|
specCtx[0].Content[0].Text = "mutated"
|
|
|
|
res, err := s.Join(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Join: %v", err)
|
|
}
|
|
if len(res.Agents) != 1 {
|
|
t.Fatalf("Agents len = %d, want 1", len(res.Agents))
|
|
}
|
|
if m.total != 1 {
|
|
t.Fatalf("runner calls = %d, want 1", m.total)
|
|
}
|
|
// The runner receives the isolated caller context plus the task itself as one
|
|
// trailing user message (裁决 2026-10-04: 任务作为一条 user 消息注入).
|
|
got := m.captured(0)
|
|
if len(got) != 2 {
|
|
t.Fatalf("runner received %d messages, want the isolated context + the task message: %#v", len(got), got)
|
|
}
|
|
if got[0].Role != model.RoleUser || got[0].Content[0].Text != "hello" {
|
|
t.Fatalf("runner context[0] = %+v, want the isolated 'hello' user message", got[0])
|
|
}
|
|
if got[1].Role != model.RoleUser || !strings.Contains(got[1].Content[0].Text, "x") {
|
|
t.Fatalf("runner context[1] = %+v, want the task user message carrying the goal", got[1])
|
|
}
|
|
if sub.Context[0].Content[0].Text != "hello" {
|
|
t.Fatalf("sub-agent context text = %q, want hello", sub.Context[0].Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestProgressEvents(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{MaxParallel: 1})
|
|
gate := make(chan struct{})
|
|
m := &mockRunner{gate: gate}
|
|
s.runner = m
|
|
|
|
listener := comm.New(s.Bus(), "main")
|
|
events := make(chan comm.Event, 8)
|
|
unsub, err := listener.SubscribeEvent(context.Background(), func(_ context.Context, e comm.Event) error {
|
|
events <- e
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SubscribeEvent: %v", err)
|
|
}
|
|
defer unsub()
|
|
|
|
if _, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}); err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
waitFor(t, func() bool { return m.activeCount() >= 1 }, waitTimeout)
|
|
close(gate)
|
|
|
|
names := collectEventNames(t, events, waitTimeout)
|
|
seen := make(map[string]bool)
|
|
for _, n := range names {
|
|
seen[n] = true
|
|
}
|
|
for _, want := range []string{EventDispatched, EventStarted, EventDone} {
|
|
if !seen[want] {
|
|
t.Fatalf("missing progress event %q, got %v", want, names)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestJoin_CanceledCtx(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{MaxParallel: 1, PerAgentTimeout: 2 * time.Second})
|
|
m := &mockRunner{gate: make(chan struct{})}
|
|
s.runner = m
|
|
|
|
if _, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"}); err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
res, err := s.Join(ctx)
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("Join err = %v, want DeadlineExceeded", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("Join returned a nil merged result on canceled ctx")
|
|
}
|
|
}
|
|
|
|
func TestApprove_NoApprover(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{})
|
|
if _, err := s.Approve(context.Background(), "bob", "run", "do something"); !errors.Is(err, ErrNoApprover) {
|
|
t.Fatalf("Approve err = %v, want ErrNoApprover", err)
|
|
}
|
|
}
|
|
|
|
func TestSinkMemory_NoStore(t *testing.T) {
|
|
t.Parallel()
|
|
s := newScheduler(t, Options{})
|
|
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"})
|
|
if err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
if _, err := s.SinkMemory(context.Background(), sub, "remember this", "persona", "relationship"); !errors.Is(err, ErrNoMemory) {
|
|
t.Fatalf("SinkMemory err = %v, want ErrNoMemory", err)
|
|
}
|
|
}
|
|
|
|
func TestRunWithRealLoop(t *testing.T) {
|
|
t.Parallel()
|
|
ad := &mockAdapter{resp: model.Response{Content: "final answer", Model: "mock"}}
|
|
ag, err := agent.New("You are a helper.", nil, nil, agent.WithModel(ad, nil))
|
|
if err != nil {
|
|
t.Fatalf("agent.New: %v", err)
|
|
}
|
|
lp := loop.New(loop.Options{Agent: ag, MaxRounds: 3})
|
|
s := newScheduler(t, Options{Loop: lp, MaxParallel: 2})
|
|
|
|
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "x"})
|
|
if err != nil {
|
|
t.Fatalf("Spawn: %v", err)
|
|
}
|
|
waitFor(t, func() bool { return sub.Status() == StatusDone }, waitTimeout)
|
|
if sub.Result() == nil {
|
|
t.Fatal("real loop produced no result")
|
|
}
|
|
if sub.Result().Text != "final answer" {
|
|
t.Fatalf("result text = %q, want final answer", sub.Result().Text)
|
|
}
|
|
|
|
res, err := s.Join(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Join: %v", err)
|
|
}
|
|
if res.Succeeded != 1 || res.Failed != 0 {
|
|
t.Fatalf("merged = %d ok / %d failed, want 1 ok", res.Succeeded, res.Failed)
|
|
}
|
|
}
|
|
|
|
func TestNoGoroutineLeak(t *testing.T) {
|
|
// Not parallel: a goroutine-count assertion is noisy under parallel tests.
|
|
s := newScheduler(t, Options{MaxParallel: 2})
|
|
m := &mockRunner{result: &loop.Result{Text: "ok"}}
|
|
s.runner = m
|
|
|
|
baseline := runtime.NumGoroutine()
|
|
ctx := context.Background()
|
|
for i := 0; i < 20; i++ {
|
|
if _, err := s.Spawn(ctx, AgentSpec{Goal: fmt.Sprintf("g%d", i)}); err != nil {
|
|
t.Fatalf("Spawn %d: %v", i, err)
|
|
}
|
|
}
|
|
if _, err := s.Join(ctx); err != nil {
|
|
t.Fatalf("Join: %v", err)
|
|
}
|
|
waitFor(t, func() bool { return runtime.NumGoroutine() <= baseline+5 }, waitTimeout)
|
|
}
|