按 DevDocs/design/22 批次 1 后端(§2/§3/§4.1-4.5/§4.7/§5 验收 1-6)。
- 迁移 0011:chat_sessions.persona_id + chat_messages.persona_id(均 DEFAULT '',无回填 → 升级零风险);同步包内幂等 DDL;**真实库升级验证**(拷贝 data/cyrene.db 到临时目录跑 MigrateUp:既有 1 会话/109 消息 persona 仍为 '',原库未被触碰)
- history:SessionMeta/Message 加 PersonaID + SetSessionPersona;ListSessions/List/scan/Append 带上
- runtime:SessionEnv.PersonaID + loop.personaFor(ctx) 替换固定 l.personaID(memory/request/turn)+ **agent 池**(Options.Agents,按角色取 instructions,未命中回落)+ **提取器不再把 persona 烤死在构造期**(多角色下记忆不再永远记在 cyrene 名下)+ **thinking 旋钮改为按会话角色解析**(否则角色 Y 会用角色 X 的预算/展示)
- gateway:会话 DTO/POST/PATCH 支持 personaId;ws/rest/voice 的 OwnerEnv 硬编改为**按会话解析角色**(actor 元数据语义不变);admin memories 搜索去掉硬编 cyrene
- Docs/api-client v0.42;**两处有意的行为收紧**已写明:POST /sessions body 非法 JSON → 400(原先完全忽略 body;空/null/{} 仍 201);POST /admin/memories/search 省略 personaId 且无默认角色 → 400(原先默认查 cyrene)
- 测试:design/22 §5 验收 1-6(记忆隔离/提示词首条 system/情绪按角色/提取 persona 归属/迁移兼容)+ 进程内 e2e(断言 adapter 收到的 model.Request.Messages[0] 即该角色的 systemPrompt)
- **已知未做(留批次 2 / 待确认)**:① IM 与渠道级未做 → **§7-3「owner 私聊镜像随主会话角色」尚未生效**;② 请求级覆盖未实现(本批只做会话级);③ **§7-5 的「上下文发送角色标记」只完成存储+DTO 半边**——消息 persona 已落库且 DTO 已带,但模型上下文不从 chat_messages 重建,「第 N 条是谁说的」尚未进上下文(需确认是否属批次 2);④「切角色重建摘要」当前是空操作(压缩未接线);⑤ thinking 旋钮仅在流式路径生效(非流式 agent.RunStep 不带,A 批次既有性质)
- 验收:go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译(既有 flake 本轮未触发;同批 session_orchestrator.go 一度 501 行 → 抽出 session_tail.go)
69 lines
3.2 KiB
Go
69 lines
3.2 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
|
|
}
|