Files
Cyrene_v2/cmd/cyrene/subagent_tool_test.go
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

132 lines
4.9 KiB
Go

package main
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// TestWireSubagentToolRegistersOwnerOnly locks the composition-root wiring: the
// dispatch tool must actually land in the capability registry (otherwise
// orchestration stays library-only), and it must carry the default reachability
// markers so a group/stranger session can neither see nor call it.
func TestWireSubagentToolRegistersOwnerOnly(t *testing.T) {
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{log: testLog(), dispatcher: d, scheduler: orchestration.New(orchestration.Options{})}
a.wireSubagentTool()
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr != nil {
t.Fatalf("capability %q is not registered: %v", orchestration.SpawnToolName, gerr)
}
names := make([]string, 0)
for _, s := range d.ToolSchemas() {
names = append(names, s.Name)
}
found := false
for _, n := range names {
if n == orchestration.SpawnToolName {
found = true
}
}
if !found {
t.Fatalf("tool schemas = %v, want the spawn tool advertised", names)
}
if !restrict.SetContext(reg, restrict.ActorOwner).Allow(orchestration.SpawnToolName) {
t.Fatal("owner was denied the spawn tool")
}
if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(orchestration.SpawnToolName) {
t.Fatal("group member may invoke the spawn tool; want owner-only")
}
if restrict.SetContext(reg, restrict.ActorStranger).Allow(orchestration.SpawnToolName) {
t.Fatal("stranger may invoke the spawn tool; want owner-only")
}
// The registered capability must be the dispatch tool itself, not a placeholder.
cap, _ := reg.Get(orchestration.SpawnToolName)
sp, ok := cap.Value.(*orchestration.SpawnTool)
if !ok {
t.Fatalf("capability value = %T, want *orchestration.SpawnTool", cap.Value)
}
if _, err := sp.Execute(context.Background(), map[string]any{}); err == nil {
t.Fatal("the registered tool accepted a goal-less call")
}
}
// TestWireSubagentToolNilScheduler asserts a degraded boot (no scheduler) logs and
// skips the registration instead of panicking.
func TestWireSubagentToolNilScheduler(t *testing.T) {
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
a := &app{log: testLog(), dispatcher: d}
a.wireSubagentTool()
if _, gerr := reg.Get(orchestration.SpawnToolName); gerr == nil {
t.Fatal("the tool was registered without a scheduler")
}
}
// TestPersonaExistsMirrorsAgentPool locks the composition-root half of design/22 §8-2:
// the resolver the scheduler validates a dispatched sub-agent's target persona with
// must answer exactly like the per-role agent pool — a role is dispatchable precisely
// when the loop has an agent carrying its instructions, and nothing else is. So a
// persona with no agent is rejected at dispatch time rather than silently answering as
// the default role.
func TestPersonaExistsMirrorsAgentPool(t *testing.T) {
t.Parallel()
pool := map[string]*agent.Agent{"yuki": {}, "xilian": {}}
exists := personaExists(pool)
if !exists("yuki") || !exists("xilian") {
t.Fatal("a pooled role was reported as unrunnable")
}
if exists("nobody") || exists("") {
t.Fatal("an unpooled role id was accepted as runnable")
}
// The resolver is what orchestration gates the dispatch on, so wire it the same
// way wire.go does and check the gate agrees.
sched := orchestration.New(orchestration.Options{PersonaExists: personaExists(map[string]*agent.Agent{})})
if sched.PersonaKnown("yuki") {
t.Fatal("an empty pool accepted a persona; want the dispatch rejected")
}
if !sched.PersonaKnown("") {
t.Fatal("a persona-less dispatch was rejected by an empty pool; want it unaffected")
}
}
// TestSubagentToolTimeoutFromConfig 锁定组合根的配置接法(design/05 §7 裁决
// 02.4.4):未配置时回落 10min 代码默认,SUBAGENT_TIMEOUT_SEC 生效时被真实
// 采用——派发工具拿到的是配置值而不是常量。
func TestSubagentToolTimeoutFromConfig(t *testing.T) {
a := &app{log: testLog()}
if got := a.subagentToolTimeout(); got != 10*time.Minute {
t.Fatalf("subagentToolTimeout() = %v, want the 10min code default", got)
}
a.cfg = &config.Config{}
a.cfg.Subagent.TimeoutSec = 90
if got := a.subagentToolTimeout(); got != 90*time.Second {
t.Fatalf("subagentToolTimeout() = %v, want 90s from config", got)
}
// 非法(非正)配置不缩小时限:保持代码默认而不是把子代理变成「立即超时」。
a.cfg.Subagent.TimeoutSec = -5
if got := a.subagentToolTimeout(); got != 10*time.Minute {
t.Fatalf("subagentToolTimeout() = %v for a non-positive config, want the code default", got)
}
}