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