fix(orchestration,loop): 子代理现在真的收到任务(P0)+ 工具白名单 + 超时可配(裁决 02.4.2/02.4.1/02.4.4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

执行方案阶段 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 旋钮管道)。
This commit is contained in:
AskaEth committed 2026-10-04 11:30:39 +08:00
1 parent 6e7ec56f9a
commit 7e7f53bb41
15 files changed
+974 -50

No files matched your search

@@ -0,0 +1,332 @@
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)
}
}