Files
Cyrene_v2/internal/services/gateway/persona_env.go
T
AskaEth 0624d9ee36
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(session,runtime): 多角色并存—按会话选角(迁移0011 + 按回合解析 + agent池 + 提取器修正)
按 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)
2026-10-03 20:12:29 +08:00

150 lines
6.3 KiB
Go

package gateway
import (
"context"
"errors"
"log/slog"
"net/http"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// errPersonaBindingUnsupported is returned by newSubSession when a caller asks for
// a role-bound session but the injected history store cannot bind roles. The
// handler maps it to 501 rather than pretending the binding landed.
var errPersonaBindingUnsupported = errors.New("gateway: history store does not support session persona binding")
// This file owns the gateway's turn-environment resolution (DevDocs/design/22
// §2/§4.5): which role answers, expressed as the loop.SessionEnv the turn runs
// under.
//
// Precedence (design/22 §2, top down):
// 1. request-level override — NOT implemented in this batch (batch 1 exposes the
// session-level binding; a per-request override is additive and unpersisted);
// 2. session-level binding — chat_sessions.persona_id, resolved here;
// 3. channel-level mapping — batch 2 (channels.json / channel_sessions);
// 4. global default — expressed as PersonaID == "", which the loop resolves to its
// configured default role (Options.PersonaID). That is what makes every
// pre-existing session behave exactly as before.
//
// The env's OTHER fields are untouched: ActorEnv and MemWritable still come from
// loop.OwnerEnv() for these owner-facing Web/voice paths. That is the identity red
// line — who may do what is decided by the channel actor metadata (here: a JWT
// owner), never by the role being played. Persona selection only changes the
// prompt, the memory namespace, the emotion machine and the thinking budget.
// sessionPersonaReader is the optional narrower view of HistoryStore used to read a
// session's bound role. *history.Store implements it; a store that does not (a test
// fake, or a store predating design/22) simply yields "" — the global default — so
// the gateway keeps working.
type sessionPersonaReader interface {
SessionPersona(ctx context.Context, sessionID string) (string, error)
}
// sessionPersonaWriter is the optional narrower view of HistoryStore used to rebind
// a session's role (PATCH /sessions/{id}, design/22 §4.5). *history.Store
// implements it; a store without it answers 501 on a persona write rather than
// pretending the binding landed.
type sessionPersonaWriter interface {
SetSessionPersona(ctx context.Context, sessionID, personaID string) error
}
// subSessionCreator is the optional narrower view of HistoryStore used to create a
// session already bound to a role (POST /sessions {personaId}, design/22 §4.5).
// *history.Store implements it; a store without it can only create an unbound
// session, so a request that explicitly asks for a role is refused with 501.
type subSessionCreator interface {
NewSubSessionFor(ctx context.Context, ownerID, personaID string) (string, error)
}
// turnEnvResolver builds the loop session environment for one turn. It holds the
// two read-only dependencies the resolution needs, so both the HTTP/WS handlers
// and the hub resolve roles through the same code path (design/22 §4.5: one
// resolver, not a hardcoded loop.OwnerEnv() per entry point).
type turnEnvResolver struct {
history HistoryStore
personas PersonaStore
log *slog.Logger
}
// owner returns the per-turn environment for one owner-facing turn of sessionID:
// the full-access owner env (identity unchanged) plus the session's bound role.
// A blank role means "inherit the global default"; the loop performs that final
// fallback, so the gateway never has to know the default role's id.
func (t turnEnvResolver) owner(ctx context.Context, sessionID string) loop.SessionEnv {
env := loop.OwnerEnv()
env.PersonaID = t.sessionPersona(ctx, sessionID)
return env
}
// sessionPersona returns the role bound to sessionID, or "" when the session
// inherits the global default. An unknown session, a store without the reader, or
// a read error all degrade to "" (the default) rather than failing the turn: a
// chat must never be blocked by a metadata lookup, and the fallback is the
// pre-multi-character behavior.
func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) string {
if t.history == nil || sessionID == "" {
return ""
}
reader, ok := t.history.(sessionPersonaReader)
if !ok {
return ""
}
personaID, err := reader.SessionPersona(ctx, sessionID)
if err != nil {
if t.log != nil && !errors.Is(err, history.ErrSessionNotFound) {
t.log.Debug("session persona read", "err", err, "session", sessionID)
}
return ""
}
return personaID
}
// defaultPersonaID returns the persona id of the library's default character, or
// "" when no persona store is wired or no character is marked default. It is the
// gateway-side view of owner.yaml:defaultPersona used where a role id is needed
// without a session (the admin memory search fallback, design/22 §4.5).
func (s *Server) defaultPersonaID() string {
if s.personas == nil {
return ""
}
for _, c := range s.personas.List() {
if c.Default && c.PersonaID != "" {
return c.PersonaID
}
}
return ""
}
// requireKnownPersona rejects a personaId the registry does not know, answering
// 400 and returning false. An empty id is always allowed (it means "inherit the
// global default"), and a gateway without a persona store cannot validate, so the
// binding is accepted there and simply resolves as an unknown role later.
func (s *Server) requireKnownPersona(w http.ResponseWriter, personaID string) bool {
if personaID == "" || s.personas == nil {
return true
}
if _, ok := s.personas.Persona(personaID); !ok {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "unknown personaId %q", personaID))
return false
}
return true
}
// newSubSession creates a sub session bound to personaID, using the role-aware
// creator when the store provides one. A store without it can only create an
// unbound session, so an explicit role request is refused (501) instead of
// silently dropping the binding.
func (s *Server) newSubSession(ctx context.Context, ownerID, personaID string) (string, error) {
creator, ok := s.history.(subSessionCreator)
if !ok {
if personaID != "" {
return "", errPersonaBindingUnsupported
}
return s.history.NewSubSession(ctx, ownerID)
}
return creator.NewSubSessionFor(ctx, ownerID, personaID)
}