Files
Cyrene_v2/internal/runtime/orchestration/persona.go
T
AskaEth 05d34853f0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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)② 关调度器侧角色校验 ③ 关工具侧预检
2026-10-03 21:30:45 +08:00

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)
}