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)
This commit is contained in:
1 parent
66a96d2f93
commit
0624d9ee36
46 files changed
+1908
-259
No files matched your search
@@ -104,7 +104,15 @@ type Message struct {
|
||||
// account comes from env. IM adapter-originated messages carry it; the Web main
|
||||
// session leaves it empty, so it is always '' for Web messages.
|
||||
AdapterID string
|
||||
CreatedAt int64
|
||||
// PersonaID is the role that SPOKE this message (design/22 §7-5). A session
|
||||
// may switch roles while keeping its context, so the authoring role is recorded
|
||||
// per message and the context/UI can tell "who said the Nth turn". Empty means
|
||||
// "inherit the current global default role" — the pre-multi-character rows and
|
||||
// any message written without an explicit role, so they read exactly as before.
|
||||
// It is display/attribution metadata only: identity and permissions are decided
|
||||
// by the channel actor metadata (project red line).
|
||||
PersonaID string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// SessionMeta is the wire-facing metadata of one persistent session.
|
||||
@@ -127,6 +135,14 @@ type SessionMeta struct {
|
||||
// read for every end of the account. Like LastMessage it is display-only —
|
||||
// identity and permissions never derive from it.
|
||||
UnreadCount int
|
||||
// PersonaID is the role this session belongs to (design/22 §2/§4.1). It is the
|
||||
// single source of truth for "which role answers in this conversation": the loop
|
||||
// resolves memory scope, system prompt, emotion and thinking budget from it. Empty
|
||||
// means "inherit the current global default role" (owner.yaml:defaultPersona), so
|
||||
// every pre-existing session and any session created without an explicit role
|
||||
// keeps the pre-multi-character behavior (design/22 §4.7) and still follows a
|
||||
// default-role change (design/22 §7-6).
|
||||
PersonaID string
|
||||
}
|
||||
|
||||
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
|
||||
@@ -202,6 +218,11 @@ type HistoryStore interface {
|
||||
// ErrSessionNotFound.
|
||||
SetPinned(ctx context.Context, sessionID string, pinned bool) error
|
||||
// NewSubSession creates a new sub session and returns its id (MainSessionID:uuid).
|
||||
// It binds no explicit role, so the new session inherits the global default
|
||||
// (persona_id = ''); *Store additionally offers NewSubSessionFor for the
|
||||
// role-bound variant (design/22 §4.1), which this narrow consumer-side interface
|
||||
// deliberately omits because neither channelbridge nor backthink ever creates a
|
||||
// session.
|
||||
NewSubSession(ctx context.Context, ownerID string) (string, error)
|
||||
// ListSessions returns pinned sessions first, then the main session, then every
|
||||
// sub session oldest first.
|
||||
@@ -284,10 +305,10 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
s.ensureTitle(ctx, msg.SessionID, msg.Content)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
|
||||
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
|
||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.CreatedAt)
|
||||
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.CreatedAt)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
|
||||
}
|
||||
@@ -303,7 +324,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
|
||||
// limit is clamped to [1, maxPageLimit].
|
||||
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE session_id = ?`
|
||||
args := []any{sessionID}
|
||||
if before > 0 {
|
||||
@@ -344,7 +365,7 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
|
||||
// only strictly older messages are returned. The returned slice is non-nil.
|
||||
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE platform = ? AND session_id != ?`
|
||||
args := []any{platform, MainSessionID}
|
||||
if before > 0 {
|
||||
@@ -386,7 +407,7 @@ func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int
|
||||
if adapterID == "" {
|
||||
return make([]Message, 0), nil
|
||||
}
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE adapter_id = ?`
|
||||
args := []any{adapterID}
|
||||
if before > 0 {
|
||||
@@ -422,7 +443,7 @@ func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int
|
||||
// returned slice is non-nil.
|
||||
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
|
||||
limit = clampLimit(limit)
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE adapter_id != ''`
|
||||
var args []any
|
||||
if before > 0 {
|
||||
@@ -458,7 +479,7 @@ func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message,
|
||||
if n < 1 {
|
||||
n = defaultPageLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
|
||||
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`, sessionID, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
|
||||
@@ -508,24 +529,13 @@ func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSubSession creates a sub session and returns its id (MainSessionID:uuid). ownerID is
|
||||
// accepted for interface completeness (the Web owner is identified by JWT, not stored per
|
||||
// session); it is logged at debug and not persisted.
|
||||
// NewSubSession creates a sub session with no explicit role and returns its id
|
||||
// (MainSessionID:uuid). The new session therefore inherits the global default role
|
||||
// (persona_id = ''); use NewSubSessionFor to bind a role at creation time. ownerID
|
||||
// is accepted for interface completeness (the Web owner is identified by JWT, not
|
||||
// stored per session); it is logged at debug and not persisted.
|
||||
func (s *Store) NewSubSession(ctx context.Context, ownerID string) (string, error) {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("history: new sub id: %w", err)
|
||||
}
|
||||
sessionID := MainSessionID + ":" + id
|
||||
now := time.Now().UnixMilli()
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO chat_sessions (id, type, title, created_at)
|
||||
VALUES (?, ?, ?, ?)`, sessionID, SessionSub, "", now); err != nil {
|
||||
return "", fmt.Errorf("history: insert sub session: %w", err)
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("sub session created", "session_id", sessionID, "owner_id", ownerID)
|
||||
}
|
||||
return sessionID, nil
|
||||
return s.NewSubSessionFor(ctx, ownerID, "")
|
||||
}
|
||||
|
||||
// ensureMainSession inserts the fixed main session row when it is absent, so ListSessions
|
||||
@@ -543,7 +553,7 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
|
||||
func scanMessage(sc rowScanner) (Message, error) {
|
||||
var m Message
|
||||
var mode, mood, platform, deviceID, senderName, adapterID string
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.CreatedAt); err != nil {
|
||||
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.CreatedAt); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
|
||||
@@ -590,6 +600,7 @@ CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
read_at INTEGER NOT NULL DEFAULT 0,
|
||||
persona_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
@@ -607,6 +618,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
sender_name TEXT NOT NULL DEFAULT '',
|
||||
adapter_id TEXT NOT NULL DEFAULT '',
|
||||
persona_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
|
||||
Reference in new issue
Block a user