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 }