feat(orchestration,runtime): subagent_spawn 支持指定目标角色(以该角色 instructions + 记忆域运行)
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:
1 parent
c7ecc3a249
commit
05d34853f0
14 files changed
+857
-36
No files matched your search
@@ -0,0 +1,79 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// This file owns the "派出去的子代理以目标角色身份运行" half of design/22 §8-2: a
|
||||
// sub-agent dispatched with a target persona must answer with that role's
|
||||
// instructions and use that role's memory scope, and a role this process cannot run
|
||||
// must be rejected loudly instead of silently degrading into the default role.
|
||||
//
|
||||
// The resolution itself stays in the loop (internal/runtime/loop/persona.go): every
|
||||
// role-scoped read — prompt (agent pool), memory namespace, emotion, thinking knobs
|
||||
// — is derived from the session env's PersonaID, one input, so the four can never
|
||||
// disagree. The orchestration layer therefore has exactly two jobs: validate the id,
|
||||
// and stamp it onto the sub-agent's run context. Neither touches the identity lane
|
||||
// (ActorEnv / MemWritable), which is decided structurally by the channel layer and
|
||||
// is never a persona's business (project red line: 身份识别靠元数据).
|
||||
|
||||
// PersonaKnown reports whether personaID is a role this process can run with its
|
||||
// own instructions (Options.PersonaExists). A blank id is trivially known: the
|
||||
// caller is naming no role, which is the pre-existing single-role dispatch. A nil
|
||||
// resolver makes every named role unknown, so a role-naming dispatch fails instead
|
||||
// of quietly running as the default role.
|
||||
func (s *Scheduler) PersonaKnown(personaID string) bool {
|
||||
return personaID == "" || (s.personaExists != nil && s.personaExists(personaID))
|
||||
}
|
||||
|
||||
// effectiveRole returns the role to record on the sub-agent as its binding. A
|
||||
// dispatched persona IS the binding (the sub-agent answers as that role), so it fills
|
||||
// a blank RoleID; an explicit RoleID that names no persona is left as the caller set
|
||||
// it. The recorded role is what the progress events and the ws subagent_event frame
|
||||
// report, so the frontend never shows a sub-agent running as yuki with an empty role.
|
||||
func effectiveRole(spec AgentSpec) string {
|
||||
if spec.RoleID == "" {
|
||||
return spec.PersonaID
|
||||
}
|
||||
return spec.RoleID
|
||||
}
|
||||
|
||||
// checkPersona rejects a role the process cannot run, wrapping ErrUnknownPersona so
|
||||
// a caller can classify it with errors.Is.
|
||||
func (s *Scheduler) checkPersona(personaID string) error {
|
||||
if s.PersonaKnown(personaID) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("orchestration: spawn: persona %q: %w", personaID, ErrUnknownPersona)
|
||||
}
|
||||
|
||||
// CheckPersonas validates every spec's target role before a batch is spawned, so one
|
||||
// unknown id rejects the whole dispatch instead of leaving the earlier sub-agents of
|
||||
// the batch running with no result ever reported. The dispatch tool calls it right
|
||||
// after decoding the arguments, where the ids are known but nothing has started yet.
|
||||
func (s *Scheduler) CheckPersonas(specs []AgentSpec) error {
|
||||
for _, spec := range specs {
|
||||
if err := s.checkPersona(spec.PersonaID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withPersonaEnv returns ctx with the target role stamped onto the session env, so
|
||||
// the loop resolves that role's prompt and memory scope for every step of the
|
||||
// sub-agent's run. The rest of the env (the identity lane and the memory-write gate)
|
||||
// is copied unchanged — a persona decides how she answers, never what she may touch.
|
||||
// A blank id returns ctx untouched, so a persona-less dispatch keeps inheriting the
|
||||
// spawning turn's env exactly as before.
|
||||
func withPersonaEnv(ctx context.Context, personaID string) context.Context {
|
||||
if personaID == "" {
|
||||
return ctx
|
||||
}
|
||||
env := loop.SessionEnvFrom(ctx)
|
||||
env.PersonaID = personaID
|
||||
return loop.WithSessionEnv(ctx, env)
|
||||
}
|
||||
Reference in new issue
Block a user