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)② 关调度器侧角色校验 ③ 关工具侧预检
80 lines
3.5 KiB
Go
80 lines
3.5 KiB
Go
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)
|
|
}
|