design/22 §8-2(主人 2026-10-03 拍板):跨角色协作 = 把其他角色以子代理形式派出去,
该子代理以目标角色的 instructions 运行、并按目标角色的记忆作用域读写。
- AgentSpec / SubAgent / SubAgentResult 新增 PersonaID;Options.PersonaExists 作角色解析器
- scheduler:派发前校验角色(ErrUnknownPersona),批量 all-or-nothing;
run ctx 经 withPersonaEnv 只覆盖 SessionEnv.PersonaID —— ActorEnv/MemWritable 原样复制
(身份识别靠 channel 元数据,persona 决定她怎么答、不决定她能碰什么:项目红线)
- 记忆归属键改为 PersonaID 优先(回落 RoleID)→ 替班者写的是目标角色的记忆域
- spawntool:schema 顶层与 tasks[] 新增可选 persona;省略 = 与旧行为逐字节一致
- 分支根节点新增 props.persona(可观测性);SinkMemory 移入 memory.go(守 500 行红线)
- 组合根:personaExists(agent 池);cmd/cyrene/wire.go +5 行
契约:Docs/api-client.md -> v0.47(graph_update 新增可选 props.persona,纯增量)
验证:go build/vet ./... 绿;internal/runtime/orchestration + cmd/cyrene 测试绿;
go test -race ./internal/runtime/orchestration -run Persona 绿;
linux/amd64 + darwin/arm64 交叉编译绿
变异验证:① 目标角色换回父/默认角色(3 处断言 FAIL)② 关调度器侧角色校验 ③ 关工具侧预检
108 lines
3.9 KiB
Go
108 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"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/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")
|
|
}
|
|
}
|