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 // (instructionsFromPersona, the very same assembly point as the default agent — // see personaInstructions), design/01 §2.2. It returns an empty map when no // persona is registered, so the loop keeps its single default agent. // // 池里的指令是**静态**的,因此主人身份说明书不在其中:那段知识按会话注入 // (loop.Options.PersonaKnowledgeFor),否则它会跟着每个角色进入群聊与 // 陌生人私聊(design/11 §0 红线)。 // // 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 } // 该角色的 agent 必须绑它自己的 persona 视图:否则 models.json 的 personaModels / // personaProviders(角色级模型/提供商路由)在生产里不会被消费,形同虚设。 stack := llm.ForPersona(c.PersonaID) ag, err := agent.New(a.personaInstructions(p), dispatcher, guards, agent.WithModel(stack, stack), 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 } }