Files
Cyrene_v2/cmd/cyrene/persona_agents.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

83 lines
3.9 KiB
Go

package main
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// This file builds the per-role agent pool (design/22 §4.2 方案 A). An
// agent.Agent's instructions are immutable after construction, so "the session's
// role decides the system prompt" is implemented as one agent per role, all
// sharing the same dispatcher, guardrail pipeline, model adapter and options —
// only the instruction string differs. The loop picks the pool entry for the
// turn's role on every step (internal/runtime/loop/persona.go), so a role switch
// takes effect on the next turn without rebuilding the loop.
//
// Requirements from design/22 §5: prompt, memory, emotion and thinking must all
// be resolved from the same role. The pool owns the prompt; the loop owns the
// other three (personaFor / knobsFor), so a turn can never mix two roles.
// personaAgentPool returns the per-role agent pool plus the agent to run turns
// with: the pooled agent for the default role when the pool has one, otherwise the
// caller's fallback agent, so a deployment with no registered character keeps
// exactly the pre-multi-character single agent.
func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline, fallback *agent.Agent) (map[string]*agent.Agent, *agent.Agent) {
pool := a.buildPersonaAgents(llm, dispatcher, guards)
if pooled, ok := pool[a.defaultPersonaID()]; ok {
return pool, pooled
}
return pool, fallback
}
// buildPersonaAgents returns the role → agent pool: every registered character's
// persona gets an agent carrying that persona's system instruction
// (identityInstructionFor + the personality/values/speechStyle/catchphrase/
// background sections, design/01 §2.2). It returns an empty map when no persona is
// registered, so the loop keeps its single default agent.
//
// Only personas reachable through a registered character are pooled: a character
// is the selectable unit (1 character ↔ 1 persona), so a persona with no character
// can never be bound to a session. A session bound to an unknown id therefore
// falls back to the default agent, and its memory scope still follows the bound
// id — a stale binding degrades to the default prompt rather than failing the
// turn.
func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline) map[string]*agent.Agent {
log := logger.WithSource(a.log, "cmd/cyrene")
pool := make(map[string]*agent.Agent, a.personas.CountPersonas())
for _, c := range a.personas.List() {
if _, ok := pool[c.PersonaID]; ok {
continue
}
p, ok := a.personas.Persona(c.PersonaID)
if !ok || p == nil {
continue
}
ag, err := agent.New(instructionsFromPersona(p), dispatcher, guards,
agent.WithModel(llm, llm),
agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"}))
if err != nil {
log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err)
continue
}
pool[c.PersonaID] = ag
}
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
}
}