feat(orchestration,runtime): subagent_spawn 支持指定目标角色(以该角色 instructions + 记忆域运行)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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)② 关调度器侧角色校验 ③ 关工具侧预检
This commit is contained in:
AskaEth committed 2026-10-03 21:30:45 +08:00
1 parent c7ecc3a249
commit 05d34853f0
14 files changed
+857 -36

No files matched your search

+14
View File
@@ -66,3 +66,17 @@ func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatc
}
return pool
}
// personaExists returns the resolver the orchestration layer validates a dispatched
// sub-agent's target role with (design/22 §8-2). A role is dispatchable exactly when
// the pool has an agent for it, which is precisely the condition under which the loop
// would answer with that role's instructions. Every other answer — an unregistered
// persona, a persona with no character, an empty pool — makes the dispatch fail with
// orchestration.ErrUnknownPersona instead of quietly running the sub-agent as the
// default role.
func personaExists(pool map[string]*agent.Agent) func(personaID string) bool {
return func(personaID string) bool {
_, ok := pool[personaID]
return ok
}
}
+29
View File
@@ -6,6 +6,7 @@ import (
"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"
)
@@ -76,3 +77,31 @@ func TestWireSubagentToolNilScheduler(t *testing.T) {
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")
}
}
+5
View File
@@ -455,6 +455,11 @@ func (a *app) wireRuntime() error {
Bus: a.bus,
Memory: store,
Tracer: tracer,
// A sub-agent dispatched as another role (subagent_spawn `persona`, design/22
// §8-2) may only name a role this process can actually run with its own
// instructions, i.e. one the pool above has an agent for; anything else is
// rejected instead of silently answering in the default role's voice.
PersonaExists: personaExists(agents),
// The sub-agent's span and its inner llm/tool steps fold into the spawning
// session's live graph, so a branch shows up as a swimlane of that session's
// flow instead of opening a second graph (design/19 §2).