按 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)
150 lines
6.3 KiB
Go
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)
|
|
}
|