feat(session,runtime): 多角色并存—按会话选角(迁移0011 + 按回合解析 + agent池 + 提取器修正)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

按 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:
AskaEth committed 2026-10-03 20:12:29 +08:00
1 parent 66a96d2f93
commit 0624d9ee36
46 files changed
+1908 -259

No files matched your search

@@ -0,0 +1,84 @@
package db
import (
"context"
"testing"
)
// TestMigrate0011SessionPersonaColumn covers design/22 acceptance 6: after upgrading a
// database that only has the 0010 schema, both new columns exist with the ''
// default, and every pre-existing row reads back as unbound — '' means "inherit the
// global default role", so no backfill is needed and the old behavior is preserved.
// It also locks the down migration (both columns dropped) and the idempotent re-up.
func TestMigrate0011SessionPersonaColumn(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn := openMemory(t)
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp: %v", err)
}
for _, col := range []struct{ table, column string }{
{"chat_sessions", "persona_id"},
{"chat_messages", "persona_id"},
} {
if !columnExists(t, ctx, conn, col.table, col.column) {
t.Fatalf("%s.%s should exist after MigrateUp", col.table, col.column)
}
}
// A row written before 0011 was added (here: written without the column, exactly
// what the ALTER's DEFAULT produces) must read back as the inherit-the-default
// value, and a legacy session must still be usable by the store.
legacySession := `INSERT INTO chat_sessions (id, type, title, pinned, created_at) VALUES ('web:legacy', 'sub', '', 0, 100)`
if _, err := conn.ExecContext(ctx, legacySession); err != nil {
t.Fatalf("insert legacy session: %v", err)
}
legacyMessage := `INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES ('m1', 'web:legacy', 'user', 'hi', 200)`
if _, err := conn.ExecContext(ctx, legacyMessage); err != nil {
t.Fatalf("insert legacy message: %v", err)
}
var sessionPersona, messagePersona string
if err := conn.QueryRowContext(ctx, `SELECT persona_id FROM chat_sessions WHERE id = 'web:legacy'`).Scan(&sessionPersona); err != nil {
t.Fatalf("read legacy session persona: %v", err)
}
if err := conn.QueryRowContext(ctx, `SELECT persona_id FROM chat_messages WHERE id = 'm1'`).Scan(&messagePersona); err != nil {
t.Fatalf("read legacy message persona: %v", err)
}
if sessionPersona != "" || messagePersona != "" {
t.Fatalf("legacy rows persona_id = (%q, %q), want ('', '') — inherit the global default", sessionPersona, messagePersona)
}
// Up twice: the recorded migration is skipped and nothing errors.
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("MigrateUp second call: %v", err)
}
// Roll back everything newer than 0010 (0011 itself plus any future migration),
// so this test keeps validating the 0011 down no matter how the repo grows.
steps := len(upMigrationIDs(t)) - 10
if err := MigrateDown(ctx, conn, steps, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", steps, err)
}
for _, col := range []struct{ table, column string }{
{"chat_sessions", "persona_id"},
{"chat_messages", "persona_id"},
} {
if columnExists(t, ctx, conn, col.table, col.column) {
t.Fatalf("%s.%s should be dropped by the 0011 down", col.table, col.column)
}
}
// Re-applying restores both columns.
if err := MigrateUp(ctx, conn, testLogger()); err != nil {
t.Fatalf("re-MigrateUp: %v", err)
}
for _, col := range []struct{ table, column string }{
{"chat_sessions", "persona_id"},
{"chat_messages", "persona_id"},
} {
if !columnExists(t, ctx, conn, col.table, col.column) {
t.Fatalf("%s.%s should exist again after re-MigrateUp", col.table, col.column)
}
}
}
+5 -3
View File
@@ -175,9 +175,11 @@ func TestMigrateDown_StepsBeyondApplied_边界(t *testing.T) {
t.Fatalf("MigrateUp: %v", err)
}
// More steps than applied must roll everything back without error.
if err := MigrateDown(ctx, db, 10, testLogger()); err != nil {
t.Fatalf("MigrateDown(10): %v", err)
// More steps than applied must roll everything back without error. The count is
// derived from the embedded migration set rather than hardcoded, so adding a
// migration cannot silently leave the oldest one applied.
if err := MigrateDown(ctx, db, len(upMigrationIDs(t))+1, testLogger()); err != nil {
t.Fatalf("MigrateDown(all+1): %v", err)
}
if appMetaExists(t, ctx, db) {
t.Fatal("app_meta should be dropped when rolling back all")
@@ -0,0 +1,11 @@
-- 0011_session_persona down: drop the role-binding columns added by the up
-- migration. SQLite DROP COLUMN (3.35+) is already relied on by 0006/0007/0009/0010,
-- and neither column is referenced by an index, view or constraint, so a plain drop
-- is safe. The rolled-back state is the pre-multi-character one: a session belongs
-- to no explicit role and every row resolves to the global default role
-- (persona_id = '' is itself the "inherit the default" value, so the dropped data
-- only ever held explicit non-default bindings — the code was the only consumer).
ALTER TABLE chat_messages DROP COLUMN persona_id;
ALTER TABLE chat_sessions DROP COLUMN persona_id;
@@ -0,0 +1,33 @@
-- 0011_session_persona: per-session role binding + per-message authoring role
-- (DevDocs/design/22 多角色并存 + 按会话选角 §4.1 / §7-5, 批次 1).
--
-- chat_sessions.persona_id: which role a conversation belongs to. It is the
-- single source of truth for "this session is Cyrene / this session is Y"
-- (design/22 §2: session-level wins, channel-level only seeds a new session,
-- request-level is a temporary override that is never persisted).
--
-- chat_messages.persona_id: the role that SPOKE a message, so the context and
-- the UI can tell "who said the Nth turn" after a session switched roles
-- (design/22 §7-5: switching keeps the context, so each message must carry its
-- authoring role).
--
-- '' means "inherit the current global default role" (owner.yaml:defaultPersona).
-- That is why NO backfill is needed: every pre-existing session and message keeps
-- persona_id = '' and therefore keeps resolving to the default role, i.e. the
-- pre-multi-character behavior is byte-for-byte unchanged (design/22 §4.7), and
-- changing owner.yaml:defaultPersona still moves those rows (design/22 §7-6).
--
-- The 0003 chat_sessions / chat_messages tables already exist at this point, so
-- ALTER ADD COLUMN is the correct, non-destructive step. The canonical schema
-- (including these columns) is also re-issued idempotently by
-- internal/runtime/history.NewStore so a fresh in-memory store used by tests stays
-- self-contained; the migration is the authoritative path for an existing
-- database.
--
-- No index is added: persona_id is never a query predicate on the hot paths
-- (sessions are read by primary key, messages by session_id + created_at, which
-- idx_chat_msgs_session_created already covers). It is read as a column only.
ALTER TABLE chat_sessions ADD COLUMN persona_id TEXT NOT NULL DEFAULT '';
ALTER TABLE chat_messages ADD COLUMN persona_id TEXT NOT NULL DEFAULT '';
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// [1, maxPageLimit].
func (s *Store) ListAfter(ctx context.Context, sessionID string, after 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 = ? AND created_at > ?`
args := []any{sessionID, after}
if platform != "" {
+2 -2
View File
@@ -27,7 +27,7 @@ func (s *Store) Search(ctx context.Context, sessionID, query string, before int6
if query == "" {
return make([]Message, 0), nil
}
sqlText := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
sqlText := `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 = ? AND content LIKE ? ESCAPE '\'`
args := []any{sessionID, "%" + escapeLike(query) + "%"}
if before > 0 {
@@ -103,7 +103,7 @@ func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) {
// messageByID reads one message by id, returning ErrMessageNotFound when the row is
// absent.
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, created_at
row := s.db.QueryRowContext(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 id = ?`, id)
m, err := scanMessage(row)
if err != nil {
@@ -0,0 +1,81 @@
package history
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// This file owns the per-session role binding (DevDocs/design/22 多角色并存 +
// 按会话选角 §2/§4.1). A session's role is the single source of truth for "which
// role answers in this conversation": the loop resolves the system prompt, the
// memory scope, the emotion machine and the thinking budget from it. The value
// is display/scope metadata only — identity and permissions are still decided by
// the channel actor metadata (project red line).
//
// '' means "inherit the current global default role", so it is both the column
// default and a legal explicit value: setting it clears an explicit binding and
// lets the session follow a default-role change again (design/22 §7-6).
// NewSubSessionFor creates a sub session bound to personaID and returns its id
// (MainSessionID:uuid). A blank personaID binds no explicit role, so the session
// inherits the global default — the same behavior as NewSubSession. ownerID is
// accepted for symmetry with NewSubSession (the Web owner is identified by JWT,
// not stored per session); it is logged at debug and not persisted.
func (s *Store) NewSubSessionFor(ctx context.Context, ownerID, personaID 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, persona_id, created_at)
VALUES (?, ?, ?, ?, ?)`, sessionID, SessionSub, "", strings.TrimSpace(personaID), 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, "persona_id", strings.TrimSpace(personaID))
}
return sessionID, nil
}
// SetSessionPersona binds (or clears, when personaID is blank) a session's role.
// Both the fixed main session and sub sessions may be retargeted: the binding is
// scope metadata, not identity. An unknown id returns ErrSessionNotFound. It
// never touches the conversation: the stored messages keep the role that spoke
// them (design/22 §7-5), and the next turn simply resolves the new role.
func (s *Store) SetSessionPersona(ctx context.Context, sessionID, personaID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_sessions SET persona_id = ? WHERE id = ?`,
strings.TrimSpace(personaID), sessionID)
if err != nil {
return fmt.Errorf("history: set session persona %s: %w", sessionID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: set session persona %s: %w", sessionID, err)
}
if n == 0 {
return fmt.Errorf("history: set session persona %s: %w", sessionID, ErrSessionNotFound)
}
return nil
}
// SessionPersona returns the role bound to sessionID, or "" when the session
// inherits the global default. An unknown id returns ErrSessionNotFound so a
// caller can tell "no explicit role" (a real session, empty binding) from "no
// such session"; a caller that only needs the effective role treats both as
// "use the default".
func (s *Store) SessionPersona(ctx context.Context, sessionID string) (string, error) {
var personaID string
err := s.db.QueryRowContext(ctx, `SELECT persona_id FROM chat_sessions WHERE id = ?`, sessionID).Scan(&personaID)
switch {
case errors.Is(err, sql.ErrNoRows):
return "", fmt.Errorf("history: session persona %s: %w", sessionID, ErrSessionNotFound)
case err != nil:
return "", fmt.Errorf("history: session persona %s: %w", sessionID, err)
}
return personaID, nil
}
@@ -0,0 +1,172 @@
package history
import (
"context"
"errors"
"testing"
)
// personaOf returns the stored role binding of a session, failing the test when the
// session is absent from the list.
func personaOf(t *testing.T, st *Store, id string) string {
t.Helper()
sessions, err := st.ListSessions(context.Background())
if err != nil {
t.Fatalf("list sessions: %v", err)
}
for _, m := range sessions {
if m.ID == id {
return m.PersonaID
}
}
t.Fatalf("session %q not found in %+v", id, sessions)
return ""
}
// TestSessionPersonaRoundTrip is design/22 acceptance 1: the session-level role is
// the single source of truth, so a value written through SetSessionPersona must come
// back from both the direct read and the list projection (the DTO the client gets),
// and the main session — which always exists — starts out unbound ('' = inherit the
// global default). An explicit "" clears the binding back to that same state.
func TestSessionPersonaRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
if got := personaOf(t, st, MainSessionID); got != "" {
t.Fatalf("main session persona_id = %q, want '' (inherit the global default)", got)
}
if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != "" {
t.Fatalf("SessionPersona(main) = (%q, %v), want ('', nil)", got, err)
}
for _, personaID := range []string{"cyrene", "yuki"} {
if err := st.SetSessionPersona(ctx, MainSessionID, personaID); err != nil {
t.Fatalf("SetSessionPersona(%s): %v", personaID, err)
}
if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != personaID {
t.Fatalf("SessionPersona(main) = (%q, %v), want (%q, nil)", got, err, personaID)
}
if got := personaOf(t, st, MainSessionID); got != personaID {
t.Fatalf("ListSessions persona_id = %q, want %q", got, personaID)
}
}
// An explicit empty value is the documented "unbind" (follow the default again),
// not a no-op.
if err := st.SetSessionPersona(ctx, MainSessionID, ""); err != nil {
t.Fatalf("SetSessionPersona(\"\"): %v", err)
}
if got := personaOf(t, st, MainSessionID); got != "" {
t.Fatalf("after unbind persona_id = %q, want ''", got)
}
}
// TestSessionPersonaUnknownSession covers the error contract: binding or reading the
// role of a session that does not exist reports ErrSessionNotFound (so the HTTP layer
// answers 404) instead of silently creating/looking up nothing.
func TestSessionPersonaUnknownSession(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
if err := st.SetSessionPersona(ctx, "web:missing", "cyrene"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("SetSessionPersona(missing) err = %v, want ErrSessionNotFound", err)
}
if _, err := st.SessionPersona(ctx, "web:missing"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("SessionPersona(missing) err = %v, want ErrSessionNotFound", err)
}
}
// TestNewSubSessionForBindsPersona locks the create path: a sub session created with a
// role carries it from its first read (no extra PATCH needed), and the plain
// NewSubSession still creates an unbound session — the pre-multi-character contract,
// which every existing caller relies on.
func TestNewSubSessionForBindsPersona(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
bound, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
if got := personaOf(t, st, bound); got != "yuki" {
t.Fatalf("bound sub persona_id = %q, want yuki", got)
}
plain, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
}
if got := personaOf(t, st, plain); got != "" {
t.Fatalf("plain sub persona_id = %q, want ''", got)
}
}
// TestMessagePersonaPersisted proves design/22 §7-5's storage half: a session may
// switch roles while keeping its context, so every message records the role that
// SPOKE it. The column survives the write/read round trip through the same scanner
// the REST/WS DTOs use, and messages written without a role stay '' (= the default
// role), which is what keeps pre-upgrade history readable.
func TestMessagePersonaPersisted(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "yuki 你好", PersonaID: "yuki"}); err != nil {
t.Fatalf("append with persona: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "在~"}); err != nil {
t.Fatalf("append without persona: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("listed %d messages, want 2", len(msgs))
}
byRole := map[string]string{}
for _, m := range msgs {
byRole[m.Role] = m.PersonaID
}
if got := byRole[RoleUser]; got != "yuki" {
t.Fatalf("user message persona_id = %q, want yuki", got)
}
if got := byRole[RoleAssistant]; got != "" {
t.Fatalf("unattributed message persona_id = %q, want ''", got)
}
// The persona column must also survive Recent (the WS replay path) and
// AppendFull (the orchestrator's realtime-sync path).
recent, err := st.Recent(ctx, MainSessionID, 10)
if err != nil {
t.Fatalf("recent: %v", err)
}
found := false
for _, m := range recent {
if m.PersonaID == "yuki" {
found = true
}
}
if !found {
t.Fatalf("Recent lost the message persona: %+v", recent)
}
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "记住了", PersonaID: "yuki"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.PersonaID != "yuki" {
t.Fatalf("AppendFull returned persona_id = %q, want yuki", full.PersonaID)
}
after, err := st.ListAfter(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(after) == 0 || after[len(after)-1].PersonaID != "yuki" {
t.Fatalf("ListAfter lost the message persona: %+v", after)
}
}
+2 -2
View File
@@ -35,7 +35,7 @@ type MessageSummary struct {
// per-session round trip (N+1) is needed and no extra index is required. Ordering floats
// pinned sessions first, then keeps the main session before unpinned subs, then subs
// oldest-first.
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at, s.persona_id,
m.role, m.content, m.created_at,
(SELECT COUNT(*) FROM chat_messages c
WHERE c.session_id = s.id AND c.created_at > s.read_at)
@@ -87,7 +87,7 @@ func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
content sql.NullString
lastAt sql.NullInt64
)
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &meta.PersonaID, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
return SessionMeta{}, err
}
meta.Type = typeStr
+12 -5
View File
@@ -186,9 +186,9 @@ func TestMarkSessionReadUnknownSession(t *testing.T) {
}
}
// migrationReadAtSQL reads one of the 0010 migration files from the source tree, so the
// test applies the same SQL text the embedded migration runs rather than a copy.
func migrationReadAtSQL(t *testing.T, name string) string {
// migrationFileSQL reads one migration file from the source tree, so the test applies
// the same SQL text the embedded migration runs rather than a copy.
func migrationFileSQL(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name))
if err != nil {
@@ -286,9 +286,16 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
t.Fatal("legacy schema should not have read_at before the migration")
}
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
t.Fatalf("apply 0010 up: %v", err)
}
// Bring the simulated legacy database up to the current head (0011 adds the
// session/message persona columns), because the store reads the canonical schema:
// a database left one migration behind would lack persona_id. Upgrade ordering in
// production is db.MigrateUp, so this mirrors what a real upgrade produces.
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0011_session_persona.up.sql")); err != nil {
t.Fatalf("apply 0011 up: %v", err)
}
if !columnPresent(t, conn, "chat_sessions", "read_at") {
t.Fatal("read_at should exist after the 0010 up migration")
}
@@ -342,7 +349,7 @@ func TestMigration0010ReadAtBackfill(t *testing.T) {
t.Fatalf("unreadCount after mark = %d, want 0", got)
}
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
t.Fatalf("apply 0010 down: %v", err)
}
if columnPresent(t, conn, "chat_sessions", "read_at") {
+39 -27
View File
@@ -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
)`
+8 -2
View File
@@ -52,7 +52,8 @@ const (
// Sentinel errors returned by the Loop. Use errors.Is to test for them; they are
// returned directly or wrapped with %w so the underlying cause stays available.
var (
// ErrNilAgent is returned by Run when no *agent.Agent was configured.
// ErrNilAgent is returned by Run when neither a default agent (Options.Agent)
// nor a per-role agent pool (Options.Agents) was configured.
ErrNilAgent = errors.New("loop: nil agent")
// ErrNoAdapter is returned by Run (streaming path) when the agent was built
@@ -85,6 +86,7 @@ type Result struct {
// they are by contract. Run keeps all loop state on the stack.
type Loop struct {
agent *agent.Agent
agents map[string]*agent.Agent
tools *tool.Dispatcher
capacity *capacity.Decider
onCapacity func(capacity.Event)
@@ -97,6 +99,7 @@ type Loop struct {
flow *FlowPublisher
guards *guardrail.Pipeline
personaID string
personaKnobs func(personaID string) (LoopKnobs, bool)
timeLocation *time.Location
timeZoneName string
maxRounds int
@@ -141,6 +144,7 @@ func New(opts Options) *Loop {
timeLocation, timeZoneName := loadTimezone(opts.Timezone, sourcedLog(opts.Log))
return &Loop{
agent: opts.Agent,
agents: opts.Agents,
tools: opts.Tools,
capacity: opts.Capacity,
onCapacity: opts.OnCapacity,
@@ -153,6 +157,7 @@ func New(opts Options) *Loop {
flow: opts.Flow,
guards: opts.Guards,
personaID: opts.PersonaID,
personaKnobs: opts.PersonaKnobs,
timeLocation: timeLocation,
timeZoneName: timeZoneName,
maxRounds: maxRounds,
@@ -176,7 +181,8 @@ func New(opts Options) *Loop {
// MaxRounds returns the tool-loop upper bound configured for the loop.
func (l *Loop) MaxRounds() int { return l.maxRounds }
// Agent returns the injected agent.
// Agent returns the injected default agent (Options.Agent). The per-role pool is
// reached through the turn's context, not this getter.
func (l *Loop) Agent() *agent.Agent { return l.agent }
// sourcedLog returns a logger tagged with the module source "runtime/loop",
+21 -15
View File
@@ -36,11 +36,12 @@ type asyncExtractor interface {
// project memory is injected so no owner-sensitive memory leaks to a group or
// stranger.
func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message {
if l.retriever == nil || l.personaID == "" {
personaID := l.personaFor(ctx)
if l.retriever == nil || personaID == "" {
return messages
}
env := sessionEnvFrom(ctx)
block, err := l.retrieveMemoryBlock(ctx, env, queryFromMessages(messages))
block, err := l.retrieveMemoryBlock(ctx, env, personaID, queryFromMessages(messages))
if err != nil {
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
return messages
@@ -54,16 +55,16 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod
return out
}
// retrieveMemoryBlock returns the memory injection block for env. The owner-full
// env keeps the pre-existing Inject behavior. The restricted env (group chat /
// stranger) injects only project-scope memory (人人可读, not owner-private) and
// marks the block as group-isolated so callers can tell an isolated read from the
// owner's full read.
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, query string) (string, error) {
// retrieveMemoryBlock returns the memory injection block for env and the turn's
// role. The owner-full env keeps the pre-existing Inject behavior. The restricted
// env (group chat / stranger) injects only project-scope memory (人人可读, not
// owner-private) and marks the block as group-isolated so callers can tell an
// isolated read from the owner's full read.
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaID, query string) (string, error) {
if !env.Restricted() {
return l.retriever.Inject(ctx, l.personaID, query, 0)
return l.retriever.Inject(ctx, personaID, query, 0)
}
results, err := l.retriever.Retrieve(ctx, l.personaID, query, 0)
results, err := l.retriever.Retrieve(ctx, personaID, query, 0)
if err != nil {
return "", err
}
@@ -228,13 +229,18 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message,
}()
}
// persistMemories writes extracted items to the memory store, scoping any item
// the extractor left unscoped to the loop's persona. Store failures are logged
// and never fatal, so one rejected item cannot abort a turn.
// persistMemories writes extracted items to the memory store, stamping every item
// with the role that produced the turn. The assignment is unconditional on
// purpose: the extractor is deliberately role-agnostic (it no longer holds a
// persona baked in at construction, cmd/cyrene/extractor.go), so a multi-role
// process would otherwise file every memory under one role. A blank role (no
// persona configured at all) leaves the item as the extractor produced it. Store
// failures are logged and never fatal, so one rejected item cannot abort a turn.
func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, step int) {
personaID := l.personaFor(ctx)
for _, it := range items {
if it.PersonaID == "" && l.personaID != "" {
it.PersonaID = l.personaID
if personaID != "" {
it.PersonaID = personaID
}
if _, err := l.memory.Add(ctx, it); err != nil {
l.log.Warn("memory store", "step", step, "error", err)
+30 -6
View File
@@ -21,8 +21,20 @@ import (
// checkpoint skips persistence; a missing tracer skips tracing.
type Options struct {
// Agent is the loop's execution unit: an instruction string plus a backend
// adapter, an optional tool dispatcher and optional guardrails.
// adapter, an optional tool dispatcher and optional guardrails. It is the
// DEFAULT agent: every turn whose role has no entry in Agents (or every turn
// when Agents is unset) runs on it, so its instructions should be the global
// default role's.
Agent *agent.Agent
// Agents is the per-role agent pool (design/22 §4.2 方案 A): persona id → the
// agent carrying that role's system prompt. The loop picks the entry for the
// turn's resolved role on every step, so switching a session's role switches the
// prompt without rebuilding the loop (an Agent's instructions are immutable, so
// one agent per role is the way to keep that invariant). All entries normally
// share one dispatcher/guardrail pipeline/adapter — only the instructions differ.
// A missing or nil entry, an unknown role, or an empty pool all fall back to
// Agent. Nil keeps the pre-existing single-role behavior.
Agents map[string]*agent.Agent
// Tools is the tool dispatcher used to execute model-requested calls. When
// nil the loop falls back to the agent's own dispatcher (if any).
Tools *tool.Dispatcher
@@ -75,8 +87,21 @@ type Options struct {
// RunStep is bypassed. On the non-streaming path the agent runs its own
// guardrails inside RunStep.
Guards *guardrail.Pipeline
// PersonaID scopes memory retrieval and extraction. Blank skips both.
// PersonaID is the loop's configured role: the global default that every turn
// without an explicit SessionEnv.PersonaID runs as (design/22 §2 裁决规则 4). It
// scopes memory retrieval and extraction, the system prompt fallback and the
// emotion hook. Blank skips memory injection/extraction.
PersonaID string
// PersonaKnobs, when non-nil, resolves the reasoning knobs for a role at the
// start of every request build (design/22 §4.2), so a session bound to role Y
// uses Y's thinking budget / effort / broadcast switch rather than the default
// role's. The returned ok=false means "role unknown" and keeps the loop's
// configured defaults (Options.Thinking / ReasoningEffort / ThinkingShowToUser).
// Resolving through the role id — rather than a caller-supplied per-turn value —
// is what keeps every path (gateway, channelbridge, backthink, sub-agents) on the
// right role's knobs from a single input. Nil keeps the configured defaults for
// every turn, i.e. the pre-multi-character behavior.
PersonaKnobs func(personaID string) (LoopKnobs, bool)
// Timezone is the IANA zone used to render the per-round "current time"
// system message (e.g. "Asia/Shanghai"). Empty disables time injection
// entirely (backward compatible); a name that fails to load also disables it.
@@ -90,10 +115,9 @@ type Options struct {
// body text, tool calls and (eventually) reasoning segments independently.
OnStream func(model.StreamEvent)
// Thinking carries the reasoning knobs (budget / explicit switch) injected
// into every model request the loop builds. nil means "do not intervene",
// which is the pre-existing behavior. It is a loop option rather than a
// persona read inside the loop so the per-turn, per-role resolution a later
// multi-character batch adds only changes the wiring-time resolver.
// into every model request the loop builds when no per-role value overrides it.
// nil means "do not intervene", which is the pre-existing behavior. It is the
// DEFAULT for turns whose role has no resolved knobs (Options.PersonaKnobs).
Thinking *model.Thinking
// ReasoningEffort is the OpenAI-compatible strength knob injected into every
// model request. Empty means "do not intervene".
+86
View File
@@ -0,0 +1,86 @@
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file owns the loop's per-turn role resolution (DevDocs/design/22 §4.2):
// one process now hosts several roles at once, so everything that used to be read
// off the Loop's construction-time binding — the system prompt (via the agent),
// the memory namespace, the emotion machine and the reasoning knobs — is resolved
// from the turn's SessionEnv instead.
//
// The resolution is deliberately three tiny functions over ctx rather than a
// field on the Loop: the Loop is shared by every concurrent session, so storing a
// resolved role on it would race, while ctx already carries the per-turn env.
// Every role-scoped read must go through these helpers, so prompt, memory,
// emotion and thinking can never disagree about "who is answering".
// LoopKnobs are the per-role model/reasoning knobs injected into every request the
// loop builds. It mirrors the three construction-time options
// (Options.Thinking / Options.ReasoningEffort / Options.ThinkingShowToUser) so a
// caller can resolve them per role while the options keep their meaning as the
// default. A nil Thinking and an empty ReasoningEffort mean "do not intervene",
// and a nil ThinkingShowToUser means "broadcast thinking frames as usual".
type LoopKnobs struct {
// Thinking is the reasoning directive (budget / explicit switch) for the role.
Thinking *model.Thinking
// ReasoningEffort is the OpenAI-compatible strength knob for the role.
ReasoningEffort string
// ThinkingShowToUser gates the thinking frames broadcast for the role's turns.
ThinkingShowToUser *bool
}
// personaFor returns the role the current turn speaks as (design/22 §2): the
// session env's explicit role when the caller resolved one, otherwise the loop's
// configured role (Options.PersonaID, the global default). Both the prompt/memory
// and the emotion hook read it, so a turn can never mix two roles.
func (l *Loop) personaFor(ctx context.Context) string {
if pid := sessionEnvFrom(ctx).PersonaID; pid != "" {
return pid
}
return l.personaID
}
// agentFor returns the agent that carries the current turn's system prompt: the
// per-role agent pool entry when one exists, otherwise the loop's default agent
// (design/22 §4.2 方案 A). A session bound to a role that has no registered agent —
// an unknown/deleted persona, or a caller that wired no pool — falls back to the
// default agent rather than failing the turn, and its memory scope still follows
// personaFor, so the fallback can never silently re-scope memory.
func (l *Loop) agentFor(ctx context.Context) *agent.Agent {
if len(l.agents) > 0 {
if ag := l.agents[l.personaFor(ctx)]; ag != nil {
return ag
}
}
return l.agent
}
// knobsFor resolves the reasoning knobs for the current turn: the resolver's
// per-role values when it knows the role, otherwise the loop's configured
// defaults. A resolver that does not recognize the role (ok = false) deliberately
// keeps the defaults rather than zeroing the knobs, so an unregistered id behaves
// like the default role instead of silently disabling reasoning.
func (l *Loop) knobsFor(ctx context.Context) LoopKnobs {
def := LoopKnobs{
Thinking: l.thinking,
ReasoningEffort: l.reasoningEffort,
ThinkingShowToUser: l.thinkingShow,
}
if l.personaKnobs == nil {
return def
}
pid := l.personaFor(ctx)
if pid == "" {
return def
}
knobs, ok := l.personaKnobs(pid)
if !ok {
return def
}
return knobs
}
+277
View File
@@ -0,0 +1,277 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file locks the multi-character behavior of design/22 §4.2: prompt, memory
// scope, emotion persona and reasoning knobs must all be resolved from the SAME
// per-turn role, so a session can never answer with role Y's voice but role X's
// memory (design/22 §5 风险表 "提示词/情绪/记忆错配").
// personaAgent builds an agent whose instructions name the role, so a test can tell
// which pool entry the loop resolved from the leading system message.
func personaAgent(t *testing.T, instructions string, ad model.Adapter) *agent.Agent {
t.Helper()
ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New(%q): %v", instructions, err)
}
return ag
}
// roleEnv is the environment a session resolver produces for personaID: full-access
// owner identity (the identity lane is unchanged) plus the explicitly bound role.
func roleEnv(personaID string) SessionEnv {
return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: personaID}
}
// runPersonaTurn drives one turn for sessionID under env and fails the test on error.
func runPersonaTurn(t *testing.T, l *Loop, env SessionEnv, sessionID, text string) *Result {
t.Helper()
res, err := l.Run(WithSessionEnv(context.Background(), env), sessionID, []model.Message{model.TextMessage(model.RoleUser, text)})
if err != nil {
t.Fatalf("Run(%s): %v", sessionID, err)
}
return res
}
// lastChatRequest returns the newest request the non-streaming path recorded.
func lastChatRequest(t *testing.T, ad *mockAdapter) model.Request {
t.Helper()
if len(ad.reqs) == 0 {
t.Fatal("adapter recorded no request")
}
return ad.reqs[len(ad.reqs)-1]
}
// requestContains reports whether any message of req carries want.
func requestContains(req model.Request, want string) bool {
for _, m := range req.Messages {
if strings.Contains(messageText(m), want) {
return true
}
}
return false
}
// TestTurnResolvesSystemPromptFromSessionRole is design/22 acceptance 3: switching a
// session's role switches the identity the model is given. The first system message
// must be the bound role's prompt — that is the whole point of the per-role agent
// pool — while an unbound session and an unknown role both stay on the default role
// instead of failing the turn.
func TestTurnResolvesSystemPromptFromSessionRole(t *testing.T) {
t.Parallel()
ad := &mockAdapter{}
x := personaAgent(t, "你是角色X。", ad)
y := personaAgent(t, "你是角色Y。", ad)
l := New(Options{
Agent: x,
Agents: map[string]*agent.Agent{"X": x, "Y": y},
PersonaID: "X",
})
runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好呀")
req := lastChatRequest(t, ad)
if got := messageText(req.Messages[0]); got != "你是角色Y。" {
t.Fatalf("first system message = %q, want the bound role Y's prompt", got)
}
// A session with no explicit binding inherits the loop's configured default.
runPersonaTurn(t, l, roleEnv(""), "web", "你好")
if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" {
t.Fatalf("unbound session prompt = %q, want the default role X's prompt", got)
}
// A binding to a role with no pooled agent (deleted/unknown persona) degrades to
// the default agent rather than erroring.
runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好")
if got := messageText(lastChatRequest(t, ad).Messages[0]); got != "你是角色X。" {
t.Fatalf("unknown role prompt = %q, want the default role X's prompt", got)
}
}
// TestPersonaScopeMemoryIsolationAndExtraction is design/22 acceptance 2 and 5: the
// memory namespace follows the turn's role, so two sessions bound to different roles
// each recall only their own role's memories while two sessions of the SAME role see
// the same memories (口径 1: memory is persona-scoped, not session-scoped); and the
// extracted item is filed under the role that produced the turn — the regression
// guard for the removal of the extractor's construction-time persona.
func TestPersonaScopeMemoryIsolationAndExtraction(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
ad := &mockAdapter{}
x := personaAgent(t, "你是角色X。", ad)
y := personaAgent(t, "你是角色Y。", ad)
l := New(Options{
Agent: x,
Agents: map[string]*agent.Agent{"X": x, "Y": y},
PersonaID: "X",
Memory: store,
Retriever: memory.NewRetriever(store),
// The extractor is built WITHOUT a persona, exactly as cmd/cyrene builds it:
// the loop is responsible for stamping the turn's role onto each item.
Extractor: memory.NewManualExtractor("", memoryOwnerRoleIDForTest),
})
runPersonaTurn(t, l, roleEnv("X"), "web:x1", "记住 我喜欢喝乌龙茶")
runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "记住 我讨厌香菜")
// Acceptance 5: each extracted memory is filed under the role of its turn.
byContent := map[string]string{}
for _, it := range listMemories(t, store) {
byContent[it.Content] = it.PersonaID
}
if got := byContent["我喜欢喝乌龙茶"]; got != "X" {
t.Fatalf("memory extracted in an X turn is filed under %q, want X (items=%v)", got, byContent)
}
if got := byContent["我讨厌香菜"]; got != "Y" {
t.Fatalf("memory extracted in a Y turn is filed under %q, want Y (items=%v)", got, byContent)
}
// Acceptance 2a: the X session recalls X's memory and never Y's.
runPersonaTurn(t, l, roleEnv("X"), "web:x1", "乌龙茶怎么样")
req := lastChatRequest(t, ad)
if !requestContains(req, "我喜欢喝乌龙茶") {
t.Fatalf("X's turn did not recall X's own memory: %+v", req.Messages)
}
if requestContains(req, "我讨厌香菜") {
t.Fatalf("X's turn leaked Y's memory: %+v", req.Messages)
}
// Acceptance 2b: another session of the SAME role sees it too (memory is
// persona-scoped, so a new session does not start from a blank memory).
runPersonaTurn(t, l, roleEnv("X"), "web:x2", "乌龙茶怎么样")
if !requestContains(lastChatRequest(t, ad), "我喜欢喝乌龙茶") {
t.Fatalf("a second X session did not see X's memory: %+v", lastChatRequest(t, ad).Messages)
}
// Acceptance 2c: the Y session recalls only Y's memory.
runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "香菜怎么样")
req = lastChatRequest(t, ad)
if !requestContains(req, "我讨厌香菜") {
t.Fatalf("Y's turn did not recall Y's own memory: %+v", req.Messages)
}
if requestContains(req, "我喜欢喝乌龙茶") {
t.Fatalf("Y's turn leaked X's memory: %+v", req.Messages)
}
}
// memoryOwnerRoleIDForTest mirrors the composition root's authoring role id, so the
// test builds the extractor the same way production does.
const memoryOwnerRoleIDForTest = "owner"
// listMemories returns every stored memory item.
func listMemories(t *testing.T, store *memory.Store) []memory.MemoryItem {
t.Helper()
items, err := store.List(context.Background())
if err != nil {
t.Fatalf("memory list: %v", err)
}
return items
}
// frameAdapter streams one thinking frame and one text frame, so a test can assert
// both the request the loop built (thinking knobs) and what it broadcast
// (showToUser gate) on the streaming path.
type frameAdapter struct {
mockAdapter
}
func (f *frameAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
f.streamReqs = append(f.streamReqs, req)
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindThinkingDelta, Delta: "想一下"})
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "答案"})
}
return model.Response{Content: "答案"}, nil
}
// TestThinkingKnobsFollowTheTurnRole locks the second half of design/22 §4.2: the
// reasoning budget / effort / thinking-broadcast switch that batch A resolved once
// at wiring time must now follow the turn's role, so a session bound to role Y runs
// with Y's budget and Y's broadcast switch instead of the default role's. The
// configured options stay the fallback for an unknown role.
func TestThinkingKnobsFollowTheTurnRole(t *testing.T) {
t.Parallel()
ad := &frameAdapter{}
x := personaAgent(t, "你是角色X。", ad)
y := personaAgent(t, "你是角色Y。", ad)
hide := false
budgetX := &model.Thinking{BudgetTokens: 100}
knobs := map[string]LoopKnobs{
"X": {Thinking: budgetX, ReasoningEffort: "high"},
"Y": {ThinkingShowToUser: &hide},
}
var events []model.StreamEvent
l := New(Options{
Agent: x,
Agents: map[string]*agent.Agent{"X": x, "Y": y},
PersonaID: "X",
Thinking: budgetX,
ReasoningEffort: "high",
OnStream: func(ev model.StreamEvent) { events = append(events, ev) },
PersonaKnobs: func(personaID string) (LoopKnobs, bool) { k, ok := knobs[personaID]; return k, ok },
})
// Role X: its budget reaches the request and its thinking frames are broadcast
// (showToUser unset = the documented "broadcast as usual").
runPersonaTurn(t, l, roleEnv("X"), "web:x1", "你好")
req := ad.streamReqs[len(ad.streamReqs)-1]
if req.Thinking == nil || req.Thinking.BudgetTokens != 100 {
t.Fatalf("X's request thinking = %+v, want budget 100", req.Thinking)
}
if req.ReasoningEffort != "high" {
t.Fatalf("X's reasoning effort = %q, want high", req.ReasoningEffort)
}
if !hasThinkingFrame(events) {
t.Fatalf("X's thinking frames were withheld: %+v", events)
}
// Role Y: Y configures no budget and hides thinking frames, so Y's request must
// carry NO thinking directive (the default role's budget must not leak in) and
// only the text frame may reach the client.
events = nil
runPersonaTurn(t, l, roleEnv("Y"), "web:y1", "你好")
req = ad.streamReqs[len(ad.streamReqs)-1]
if req.Thinking != nil {
t.Fatalf("Y's request thinking = %+v, want nil (Y configures none)", req.Thinking)
}
if req.ReasoningEffort != "" {
t.Fatalf("Y's reasoning effort = %q, want empty", req.ReasoningEffort)
}
if hasThinkingFrame(events) {
t.Fatalf("Y's thinking frames were broadcast despite showToUser=false: %+v", events)
}
if len(events) == 0 {
t.Fatal("Y's text frames were suppressed too, want the answer to still stream")
}
// An unknown role keeps the loop's configured defaults instead of zeroing them.
events = nil
runPersonaTurn(t, l, roleEnv("nobody"), "web:z1", "你好")
req = ad.streamReqs[len(ad.streamReqs)-1]
if req.Thinking == nil || req.Thinking.BudgetTokens != 100 {
t.Fatalf("unknown role thinking = %+v, want the configured default budget", req.Thinking)
}
if !hasThinkingFrame(events) {
t.Fatalf("unknown role thinking frames were withheld: %+v", events)
}
}
// hasThinkingFrame reports whether the broadcast set carried a reasoning frame.
func hasThinkingFrame(events []model.StreamEvent) bool {
for _, ev := range events {
if isThinkingKind(ev.Kind) {
return true
}
}
return false
}
+23 -20
View File
@@ -8,22 +8,24 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// buildRequest assembles a model.Request from the agent's public surface,
// buildRequest assembles a model.Request from the turn's agent's public surface,
// mirroring agent.buildRequest so the streaming path behaves identically to
// RunStep (leading instruction message, tool schemas, model routing).
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message) model.Request {
// RunStep (leading instruction message, tool schemas, model routing). The agent
// and the reasoning knobs are resolved from ctx, so a session that switched roles
// gets the new role's prompt and budget on its very next step (design/22 §4.2).
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *agent.Agent, knobs LoopKnobs) model.Request {
req := model.Request{
Model: l.agent.Model(),
Messages: l.assembleMessages(messages),
Temperature: l.temperature(),
MaxTokens: l.agent.MaxTokens(),
Purpose: l.agent.Purpose(),
Model: ag.Model(),
Messages: assembleMessages(ag, messages),
Temperature: temperatureOf(ag),
MaxTokens: ag.MaxTokens(),
Purpose: ag.Purpose(),
Tools: l.toolSchemas(ctx, messages),
Thinking: l.thinking,
ReasoningEffort: l.reasoningEffort,
Thinking: knobs.Thinking,
ReasoningEffort: knobs.ReasoningEffort,
}
if l.agent.Router() != nil {
if m, _ := l.agent.Router().RouteRequest(req); m != "" {
if ag.Router() != nil {
if m, _ := ag.Router().RouteRequest(req); m != "" {
req.Model = m
}
}
@@ -32,8 +34,8 @@ func (l *Loop) buildRequest(ctx context.Context, messages []model.Message) model
// assembleMessages prepends the agent's instructions as a leading system message
// when they are non-empty, otherwise returns the input slice unchanged.
func (l *Loop) assembleMessages(messages []model.Message) []model.Message {
instr := l.agent.Instructions()
func assembleMessages(ag *agent.Agent, messages []model.Message) []model.Message {
instr := ag.Instructions()
if instr == "" {
return messages
}
@@ -43,10 +45,10 @@ func (l *Loop) assembleMessages(messages []model.Message) []model.Message {
return out
}
// temperature returns a pointer to the configured temperature, mapping zero to
// nil so the provider default applies (matching the agent package).
func (l *Loop) temperature() *float64 {
t := l.agent.Temperature()
// temperatureOf returns a pointer to the agent's configured temperature, mapping
// zero to nil so the provider default applies (matching the agent package).
func temperatureOf(ag *agent.Agent) *float64 {
t := ag.Temperature()
if t == 0 {
return nil
}
@@ -140,14 +142,15 @@ const capacityRecallTopK = 5
// habits and must never read owner-sensitive memory into a group or stranger intent
// decision (design §2.2 红线: 不读陌生人敏感).
func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []capacity.RecallItem {
if l.retriever == nil || l.personaID == "" {
personaID := l.personaFor(ctx)
if l.retriever == nil || personaID == "" {
return nil
}
env := sessionEnvFrom(ctx)
if env.Restricted() {
return nil
}
results, err := l.retriever.Retrieve(ctx, l.personaID, queryFromMessages(messages), capacityRecallTopK)
results, err := l.retriever.Retrieve(ctx, personaID, queryFromMessages(messages), capacityRecallTopK)
if err != nil {
l.log.Warn("capacity recall", "error", err, "env", env.ActorEnv)
return nil
+1 -1
View File
@@ -34,7 +34,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
if ctx == nil {
ctx = context.Background()
}
if l.agent == nil {
if l.agent == nil && len(l.agents) == 0 {
return nil, fmt.Errorf("loop: run: %w", ErrNilAgent)
}
+13 -1
View File
@@ -20,6 +20,15 @@ type SessionEnv struct {
// true only for the owner's private conversation; group chats and strangers
// are read-only (design/02 §3, design/11 §2).
MemWritable bool
// PersonaID is the role this conversation speaks as (design/22 §2/§4.2), resolved
// per turn by the caller (normally the gateway's session-binding resolver). Empty
// means "inherit the loop's configured role" (Options.PersonaID, the global default
// role), which keeps every pre-multi-character caller — channelbridge, backthink,
// internal autonomous passes — behaving exactly as before. It scopes the system
// prompt, the memory namespace, the emotion machine and the thinking budget; it is
// NOT an identity or permission input: ActorEnv/MemWritable alone decide access
// (project red line).
PersonaID string
}
// Restricted reports whether this is a group-chat or stranger conversation, i.e.
@@ -29,7 +38,10 @@ func (e SessionEnv) Restricted() bool { return !e.MemWritable }
// OwnerEnv returns the full-access owner session environment (private
// conversation, memory read+write). It is the Web main-session and internal
// automated-task default, matching the pre-existing owner-full behavior.
// automated-task default, matching the pre-existing owner-full behavior. It binds
// no explicit role (PersonaID is empty), so the loop falls back to its configured
// default role — the caller that knows the session's role sets PersonaID on the
// returned value (design/22 §4.2).
func OwnerEnv() SessionEnv {
return SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true}
}
+11 -9
View File
@@ -23,10 +23,12 @@ const (
const maxLogSummaryRunes = 240
// recordLLMDone appends a desensitized summary of a finished model call to its
// span: the configured model, both stop reasons, how many tool calls were
// span: the model that served the turn, both stop reasons, how many tool calls were
// requested and how long the call took. No prompt or completion text is recorded,
// so an llm node's log can never carry conversation content.
func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome) {
// so an llm node's log can never carry conversation content. ag is the turn's agent
// (the per-role pool entry), so a multi-role process reports the model that actually
// served the call rather than the default role's.
func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome, ag *agent.Agent) {
if span == nil || out == nil {
return
}
@@ -35,7 +37,7 @@ func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome) {
"tool_calls": len(out.toolCalls),
"elapsed_ms": elapsedMS(span),
}
if m := l.modelName(out); m != "" {
if m := modelName(out, ag); m != "" {
props["model"] = m
}
if out.modelStopReason != "" {
@@ -72,16 +74,16 @@ func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutco
}
// modelName reports the model that served a step: the routed request model when
// the streaming path knows it, else the loop's configured model. It is empty when
// neither is available.
func (l *Loop) modelName(out *stepOutcome) string {
// the streaming path knows it, else the turn's agent's configured model. It is
// empty when neither is available.
func modelName(out *stepOutcome, ag *agent.Agent) string {
if out.model != "" {
return out.model
}
if l.agent == nil {
if ag == nil {
return ""
}
return l.agent.Model()
return ag.Model()
}
// elapsedMS reports how long a span has been open so far. A span's Duration is
+4 -4
View File
@@ -57,15 +57,15 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess
l.spanProp(llmSpan, "model_stop_reason", out.modelStopReason)
}
l.spanProp(llmSpan, "tool_calls", len(out.toolCalls))
l.recordLLMDone(llmSpan, out)
l.recordLLMDone(llmSpan, out, l.agentFor(ctx))
l.finishSpan(llmSpan, trace.SpanStatusOK)
return out, nil
}
// agentStep delegates a non-streaming step to the agent, which runs input and
// output guardrails, calls the model and decodes any tool calls.
// agentStep delegates a non-streaming step to the turn's agent, which runs input
// and output guardrails, calls the model and decodes any tool calls.
func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) {
sr, err := l.agent.RunStep(ctx, sessionID, messages)
sr, err := l.agentFor(ctx).RunStep(ctx, sessionID, messages)
if err != nil {
return nil, fmt.Errorf("loop: agent step: %w", err)
}
+16 -11
View File
@@ -15,11 +15,16 @@ import (
// path, this method is owned by the loop; it mirrors the agent's semantics so the
// two behave identically apart from the delta callback.
func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepOutcome, error) {
adapter := l.agent.Adapter()
ag := l.agentFor(ctx)
adapter := ag.Adapter()
if adapter == nil {
return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter)
}
req := l.buildRequest(ctx, messages)
// Resolve the role's agent and reasoning knobs once per step: the same pair is
// used for the request and for the per-delta broadcast gate, so a role switch
// can never apply the new prompt with the old broadcast switch mid-stream.
knobs := l.knobsFor(ctx)
req := l.buildRequest(ctx, messages, ag, knobs)
if l.guards != nil {
verdict, err := l.guards.CheckInput(ctx, &req)
@@ -40,10 +45,10 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
ev.TurnID = loopTurn(ctx)
// Suppress UI broadcast for internal autonomous passes (backthink),
// whose intermediate reasoning must not leak into the chat stream, and for
// thinking frames when the bound persona turned them off (showToUser:
// false). Both gates are broadcast-only: the response the model produced is
// thinking frames when the turn's role turned them off (showToUser: false).
// Both gates are broadcast-only: the response the model produced is
// unchanged, so display and persistence stay decoupled.
if l.onStream != nil && !isMutedBroadcast(ctx) && l.broadcasts(ev) {
if l.onStream != nil && !isMutedBroadcast(ctx) && broadcasts(ev, knobs.ThinkingShowToUser) {
l.onStream(ev)
}
})
@@ -70,15 +75,15 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
}
// broadcasts reports whether a streamed event may be pushed to the UI. Only
// thinking frames are ever withheld, and only when the wiring resolved the
// persona's showToUser to false; text, tool-call and tool-progress frames are
// never affected, so turning thinking off cannot change what the client sees of
// the answer itself.
func (l *Loop) broadcasts(ev model.StreamEvent) bool {
// thinking frames are ever withheld, and only when the turn's role resolved
// showToUser to false; text, tool-call and tool-progress frames are never
// affected, so turning thinking off cannot change what the client sees of the
// answer itself.
func broadcasts(ev model.StreamEvent, showToUser *bool) bool {
if !isThinkingKind(ev.Kind) {
return true
}
return l.thinkingShow == nil || *l.thinkingShow
return showToUser == nil || *showToUser
}
// isThinkingKind reports whether kind is one of the reasoning segment events.
+5 -3
View File
@@ -18,8 +18,10 @@ import (
type TurnEnd struct {
// SessionID is the conversation the turn belonged to.
SessionID string
// PersonaID is the persona the loop is bound to (Options.PersonaID), so the
// observer stays persona-scoped (design/01 §2).
// PersonaID is the role the turn speaks as: the session env's resolved role when
// the caller supplied one, otherwise the loop's configured default
// (design/22 §4.2). It keeps the observer persona-scoped (design/01 §2): the
// emotion machine and affection of that role — and only that role — advance.
PersonaID string
// UserText is the latest user message of the turn ("" when the turn carried
// no user text, e.g. a resumed tool round).
@@ -52,7 +54,7 @@ func (l *Loop) settleTurn(ctx context.Context, sessionID string, msgs []model.Me
}
res.Mood = l.onTurnEnd(TurnEnd{
SessionID: sessionID,
PersonaID: l.personaID,
PersonaID: l.personaFor(ctx),
UserText: lastUserText(msgs),
AssistantText: res.Text,
StopReason: res.StopReason,
+5 -2
View File
@@ -38,8 +38,11 @@ type ManualExtractor struct {
roleID string
}
// NewManualExtractor builds a ManualExtractor whose extracted items are scoped
// to personaID and roleID, isolated from other personas (design §4).
// NewManualExtractor builds a ManualExtractor whose extracted items carry
// personaID/roleID as their author. Like the LLM extractor, the persona is a
// default rather than the authority: a multi-role caller builds it with a blank
// persona and stamps each item with the turn's role when persisting it
// (design/22 §4.2), so pass "" for a role-agnostic extractor.
func NewManualExtractor(personaID, roleID string) *ManualExtractor {
return &ManualExtractor{personaID: personaID, roleID: roleID}
}
+17 -6
View File
@@ -50,11 +50,15 @@ type LLMExtractor struct {
seen map[string]struct{}
}
// NewLLMExtractor builds an LLMExtractor whose extracted items are authored by
// personaID/roleID and default to ScopePersona + KindRelationship when the model
// does not specify otherwise. A nil adapter is rejected at use time via
// ErrNilAdapter, matching the lazy construction pattern of the rest of the
// runtime; callers may pass nil to build the struct and wire the adapter later.
// NewLLMExtractor builds an LLMExtractor whose extracted items default to
// ScopePersona + KindRelationship when the model does not specify otherwise, and
// carry personaID/roleID as their author. The persona is a *default*, not the
// authority: a multi-role caller (the runtime loop, design/22 §4.2) builds the
// extractor with a blank persona and stamps each item with the role that produced
// the turn, so pass "" for a role-agnostic extractor. A nil adapter is rejected at
// use time via ErrNilAdapter, matching the lazy construction pattern of the rest
// of the runtime; callers may pass nil to build the struct and wire the adapter
// later.
func NewLLMExtractor(adapter model.Adapter, personaID, roleID string) *LLMExtractor {
return &LLMExtractor{
adapter: adapter,
@@ -170,6 +174,13 @@ func parseCandidates(s string) ([]llmCandidate, error) {
// and any content already emitted by this extractor (idempotent dedup). It
// requires e.mu; only valid items mark their dedup key so a malformed candidate
// does not suppress a later valid one with the same text.
//
// The persona is NOT required here even though MemoryItem.validate demands one: a
// role-agnostic extractor (personaID == "") is legitimate — the caller that knows
// the turn's role (the loop) stamps the persona when it persists the item — and
// rejecting the item here would silently drop every memory of a multi-role
// process. Content/scope/kind, which only this package can judge, are still
// enforced.
func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
e.mu.Lock()
defer e.mu.Unlock()
@@ -195,7 +206,7 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
Confidence: c.Confidence,
SourceConversation: strings.TrimSpace(c.Source),
}
if err := it.validate(); err != nil {
if !it.Scope.valid() || !it.Kind.valid() {
continue
}
e.seen[key] = struct{}{}
@@ -53,7 +53,16 @@ func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
}
personaID := strings.TrimSpace(req.PersonaID)
if personaID == "" {
personaID = "cyrene"
// Fall back to the library's default role rather than a hardcoded id, so the
// admin search follows a renamed/replaced default (design/22 §4.5). No default
// registered means there is nothing to search: answer honestly instead of
// querying an id that cannot match.
personaID = s.defaultPersonaID()
if personaID == "" {
s.auditClaims(r, "memory.search", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "personaId is required (no default persona configured)"))
return
}
}
topK := req.TopK
if topK <= 0 {
+58 -12
View File
@@ -1,7 +1,9 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
@@ -32,6 +34,12 @@ type historySessionSummary struct {
// cursor is session-scoped and single-owner: reading on any one end clears the
// badge for every end of the account.
UnreadCount int `json:"unreadCount"`
// PersonaID is the role this session belongs to (design/22 §2/§4.5). It is the
// single source of truth for which role answers in the conversation, and the
// client uses it to render the role's name/avatar for the active session.
// Omitted when empty, and empty means "inherit the global default role", so an
// old client that ignores the field keeps showing the default role's visuals.
PersonaID string `json:"personaId,omitempty"`
}
// historyLastMessage is the wire shape of the last-message preview in a session summary.
@@ -64,7 +72,13 @@ type historyMessage struct {
// source that produced the message (e.g. "kook:3bef..."). It is blank for
// Web-originated messages.
AdapterID string `json:"adapterId,omitempty"`
CreatedAt int64 `json:"createdAt"`
// 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 stored and
// returned per message and the client renders the bubble with that role's
// name/avatar. Omitted when empty, and empty means "the global default role" —
// the pre-multi-character rows read exactly as before.
PersonaID string `json:"personaId,omitempty"`
CreatedAt int64 `json:"createdAt"`
}
// handleHistorySessions returns the main session first, then every sub session (oldest
@@ -84,6 +98,7 @@ func (s *Server) handleHistorySessions(w http.ResponseWriter, r *http.Request) {
Title: m.Title,
CreatedAt: m.CreatedAt,
Pinned: m.Pinned,
PersonaID: m.PersonaID,
LastMessage: toHistoryLastMessage(m.LastMessage),
UnreadCount: m.UnreadCount,
})
@@ -100,8 +115,21 @@ func toHistoryLastMessage(m *history.MessageSummary) *historyLastMessage {
return &historyLastMessage{Role: m.Role, Content: m.Content, CreatedAt: m.CreatedAt}
}
// createSubSessionRequest is the POST /api/v1/sessions body. The body itself is
// optional (a client that just wants a plain session sends none), and personaId is
// the optional role the new session is bound to (design/22 §4.5). It is the
// "seed the new session's role" half of the design's precedence: an explicit role
// here becomes the session-level binding (the single source of truth), while
// omitting it leaves persona_id = '' so the session inherits the global default.
type createSubSessionRequest struct {
PersonaID string `json:"personaId"`
}
// handleCreateSubSession creates a new sub session and returns its sessionId. It is a
// user-side write, so it is owner/admin-gated like /api/v1/chat.
// user-side write, so it is owner/admin-gated like /api/v1/chat. An optional
// {"personaId":"..."} body binds the new session to that role; an unknown id is
// rejected with 400 rather than creating a session that would silently answer as
// the default role.
func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -111,13 +139,30 @@ func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request)
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
ownerID = claims.Sub
}
id, err := s.history.NewSubSession(r.Context(), ownerID)
var req createSubSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
personaID := strings.TrimSpace(req.PersonaID)
if !s.requireKnownPersona(w, personaID) {
return
}
id, err := s.newSubSession(r.Context(), ownerID, personaID)
if err != nil {
if errors.Is(err, errPersonaBindingUnsupported) {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话角色绑定需要支持 personaId 的 HistoryStore", HTTPCode: http.StatusNotImplemented})
return
}
s.log.Error("history create sub session", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to create session"))
return
}
writeJSON(w, http.StatusCreated, map[string]any{"sessionId": id})
resp := map[string]any{"sessionId": id}
if personaID != "" {
resp["personaId"] = personaID
}
writeJSON(w, http.StatusCreated, resp)
}
// handleListMessages returns a page of messages for a session with cursor pagination.
@@ -345,18 +390,19 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
// toHistoryMessage maps a stored history.Message to its wire shape.
func toHistoryMessage(m history.Message) historyMessage {
return historyMessage{
ID: m.ID,
SessionID: m.SessionID,
Role: m.Role,
Content: m.Content,
Mode: m.Mode,
Mood: m.Mood,
Image: m.Image,
Thinking: m.Thinking,
ID: m.ID,
SessionID: m.SessionID,
Role: m.Role,
Content: m.Content,
Mode: m.Mode,
Mood: m.Mood,
Image: m.Image,
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
SenderName: m.SenderName,
AdapterID: m.AdapterID,
PersonaID: m.PersonaID,
CreatedAt: m.CreatedAt,
}
}
@@ -399,8 +399,8 @@ func TestPersistUserMessageNamesSession(t *testing.T) {
if err != nil {
t.Fatalf("new sub: %v", err)
}
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "帮我规划一次旅行")}, "web:test", "chat")
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "第二句话")}, "web:test", "chat")
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "帮我规划一次旅行")}, "web:test", "chat", "cyrene")
o.persistUserMessages(ctx, sub, []model.Message{model.TextMessage(model.RoleUser, "第二句话")}, "web:test", "chat", "cyrene")
metas, err := st.ListSessions(ctx)
if err != nil {
@@ -417,7 +417,7 @@ func TestPersistUserMessageNamesSession(t *testing.T) {
}
// The main session is named from its first Web user message too.
o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat")
o.persistUserMessages(ctx, history.MainSessionID, []model.Message{model.TextMessage(model.RoleUser, " 今天\n天气如何 ")}, "web:test", "chat", "cyrene")
metas, err = st.ListSessions(ctx)
if err != nil {
t.Fatalf("list sessions again: %v", err)
@@ -0,0 +1,314 @@
package gateway
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// This file covers the gateway half of design/22 (多角色并存 + 按会话选角): the REST
// contract for binding a session to a role, and the resolver that turns a
// sessionID into the loop environment the turn runs under. The chain asserted end to
// end is: sessionID → chat_sessions.persona_id → turnEnvResolver → loop.SessionEnv →
// per-role agent (system prompt), so a regression at any hop shows up as the wrong
// role answering.
// personaAdapter records every request the loop built, so a test can assert which
// role's system prompt actually reached the model.
type personaAdapter struct {
reqs []model.Request
}
func (p *personaAdapter) Name() string { return "persona-test" }
func (p *personaAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
p.reqs = append(p.reqs, req)
return model.Response{Content: "好呀"}, nil
}
func (p *personaAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return p.Chat(ctx, req, model.Options{})
}
// personaTestRegistry registers two roles (one default) so the gateway can validate
// personaId values and resolve the default role.
func personaTestRegistry(t *testing.T) *personareg.Registry {
t.Helper()
reg := personareg.New()
for _, p := range []*loader.Persona{
{ID: "xilian", Name: "昔涟"},
{ID: "yuki", Name: "由纪"},
} {
if err := reg.RegisterPersona(p); err != nil {
t.Fatalf("RegisterPersona(%s): %v", p.ID, err)
}
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "xilian", Name: "昔涟", Default: true, PersonaID: "xilian"}); err != nil {
t.Fatalf("RegisterCharacter(xilian): %v", err)
}
if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "由纪", PersonaID: "yuki"}); err != nil {
t.Fatalf("RegisterCharacter(yuki): %v", err)
}
return reg
}
// personaTestStore opens an in-memory chat-history store.
func personaTestStore(t *testing.T) *history.Store {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
st, err := history.NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("history.NewStore: %v", err)
}
return st
}
// gatewayAgentFor builds the per-role agent the composition root would build: the
// same adapter, a role-specific instruction string.
func gatewayAgentFor(t *testing.T, instructions string, ad model.Adapter) *agent.Agent {
t.Helper()
ag, err := agent.New(instructions, nil, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New(%q): %v", instructions, err)
}
return ag
}
// TestChatTurnAnswersAsTheBoundRole is design/22 acceptance 3 seen from the process
// boundary: a sub session created and bound to role Y answers as Y (the first system
// message is Y's prompt and the persisted messages carry Y), and once the session is
// rebound to "inherit the default" the next turn answers as the default role again.
func TestChatTurnAnswersAsTheBoundRole(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := personaTestStore(t)
reg := personaTestRegistry(t)
sub, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
ad := &personaAdapter{}
xilian := gatewayAgentFor(t, "你是昔涟。", ad)
yuki := gatewayAgentFor(t, "你是由纪。", ad)
l := loop.New(loop.Options{
Agent: xilian,
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
PersonaID: "xilian",
})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st, Personas: reg, Loop: l})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": sub, "text": "你好呀"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(ad.reqs) == 0 {
t.Fatal("the model adapter saw no request")
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是由纪。" {
t.Fatalf("bound session prompt = %q, want role Y's prompt", got)
}
// The orchestrator files both the user turn and the reply under the turn's role,
// which is what makes the stored transcript readable role-by-role (design/22 §7-5).
msgs, err := st.List(ctx, sub, 0, 10, "")
if err != nil {
t.Fatalf("list messages: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("persisted %d messages, want 2 (user + assistant)", len(msgs))
}
for _, m := range msgs {
if m.PersonaID != "yuki" {
t.Fatalf("%s message persona_id = %q, want yuki", m.Role, m.PersonaID)
}
}
// Unbind the role: the session inherits the global default, so the next turn is
// the default role's again.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+sub, map[string]any{"personaId": ""}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unbind status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{"sessionId": sub, "text": "再说一句"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("second chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := firstSystemText(ad.reqs[len(ad.reqs)-1]); got != "你是昔涟。" {
t.Fatalf("unbound session prompt = %q, want the default role's prompt", got)
}
}
// firstSystemText returns the text of the first system message of req.
func firstSystemText(req model.Request) string {
for _, m := range req.Messages {
if m.Role != model.RoleSystem {
continue
}
text := ""
for _, part := range m.Content {
text += part.Text
}
return text
}
return ""
}
// TestSessionPersonaHTTPContract covers the REST contract of design/22 §4.5: create
// with a personaId binds it, the session list reports it, PATCH rebinds and clears
// it, an unknown personaId is rejected (400) instead of binding a role that cannot
// answer, and a PATCH with none of title/pinned/personaId is still a 400.
func TestSessionPersonaHTTPContract(t *testing.T) {
t.Parallel()
st := personaTestStore(t)
reg := personaTestRegistry(t)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{History: st, Personas: reg})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "yuki"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
PersonaID string `json:"personaId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create: %v (body=%s)", err, body)
}
if created.PersonaID != "yuki" {
t.Fatalf("create echoed personaId = %q, want yuki", created.PersonaID)
}
// The session list surfaces the binding (the client renders the active role from it).
var sessions []historySessionSummary
_, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
decodeDataSlice(t, body, &sessions)
found := ""
for _, s := range sessions {
if s.ID == created.SessionID {
found = s.PersonaID
}
}
if found != "yuki" {
t.Fatalf("listed persona_id = %q, want yuki (sessions=%+v)", found, sessions)
}
// An unknown role is refused rather than silently ignored.
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", map[string]any{"personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("create with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": "nobody"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("patch with unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// A body with none of the supported fields stays a 400.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// Rebind to the other role, then clear the binding; both echo only the changed field.
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": "xilian"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("rebind status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "xilian" {
t.Fatalf("after rebind persona_id = %q, want xilian", got)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+created.SessionID, map[string]any{"personaId": ""}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unbind status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := personaBindingOf(t, st, created.SessionID); got != "" {
t.Fatalf("after unbind persona_id = %q, want '' (inherit the default)", got)
}
}
// personaBindingOf reads a session's bound role straight from the store, so the test
// asserts the persisted state rather than only the response echo.
func personaBindingOf(t *testing.T, st *history.Store, sessionID string) string {
t.Helper()
personaID, err := st.SessionPersona(context.Background(), sessionID)
if err != nil {
t.Fatalf("SessionPersona(%s): %v", sessionID, err)
}
return personaID
}
// TestTurnEnvResolver covers the resolver itself (design/22 §4.5): the session's
// binding wins, an unknown/unbound session yields "" (so the loop falls back to the
// global default), and the identity/memory fields stay the owner-full ones — the
// role never changes what a turn is allowed to do.
func TestTurnEnvResolver(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := personaTestStore(t)
bound, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
r := turnEnvResolver{history: st, personas: personaTestRegistry(t), log: testLogger()}
env := r.owner(ctx, bound)
if env.PersonaID != "yuki" {
t.Fatalf("bound session env persona = %q, want yuki", env.PersonaID)
}
if env.ActorEnv != loop.OwnerEnv().ActorEnv || !env.MemWritable {
t.Fatalf("resolver changed the identity lane: %+v", env)
}
unbound, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
}
if got := r.owner(ctx, unbound).PersonaID; got != "" {
t.Fatalf("unbound session env persona = %q, want '' (inherit the default)", got)
}
if got := r.owner(ctx, "sess-unknown").PersonaID; got != "" {
t.Fatalf("unknown session env persona = %q, want '' (inherit the default)", got)
}
// A gateway without a history store cannot resolve a binding and must not fail.
bare := turnEnvResolver{log: testLogger()}
if got := bare.owner(ctx, "web").PersonaID; got != "" {
t.Fatalf("no-store env persona = %q, want ''", got)
}
if !bare.owner(ctx, "web").MemWritable {
t.Fatal("no-store env lost the owner memory lane")
}
}
// TestDefaultPersonaIDFromRegistry locks the admin fallback design/22 §4.5 asks for:
// the default role is read from the library's default character instead of a
// hardcoded "cyrene", and an unwired registry reports none.
func TestDefaultPersonaIDFromRegistry(t *testing.T) {
t.Parallel()
srv, _, _ := buildEnv(t, cfgDefault(), Deps{Personas: personaTestRegistry(t)})
if got := srv.defaultPersonaID(); got != "xilian" {
t.Fatalf("defaultPersonaID = %q, want xilian (the default character's persona)", got)
}
bare, _, _ := buildEnv(t, cfgDefault(), Deps{})
if got := bare.defaultPersonaID(); got != "" {
t.Fatalf("defaultPersonaID without a registry = %q, want ''", got)
}
}
+49 -15
View File
@@ -19,12 +19,15 @@ import (
// broadcast their outcome over the WS hub (message_edited / message_deleted, see
// ws_hub.go) so the account's other browsers stay in sync without a manual re-read.
// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. Both fields are pointers
// so an absent field is distinguishable from an explicit zero value: title renames the
// session (A4), pinned sets/unets the pin (D5), and at least one must be present.
// patchSessionRequest is the PATCH /api/v1/sessions/{id} body. All fields are
// pointers so an absent field is distinguishable from an explicit zero value:
// title renames the session (A4), pinned sets/unets the pin (D5), personaId
// rebinds the session's role (design/22 §4.5, an explicit "" clears the binding
// back to "inherit the global default"), and at least one must be present.
type patchSessionRequest struct {
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
Title *string `json:"title"`
Pinned *bool `json:"pinned"`
PersonaID *string `json:"personaId"`
}
// maxSessionTitleRunes bounds a client-supplied session title so a display row never
@@ -32,12 +35,16 @@ type patchSessionRequest struct {
// the title (project red line).
const maxSessionTitleRunes = 80
// handlePatchSession updates a persistent session's display metadata: its title (A4)
// and/or its pinned flag (D5). Both are display/order-only, so unlike DELETE they are
// allowed on the fixed main session. A request carrying neither field answers 400, as
// do an empty/whitespace or over-long title; an unknown session id answers 404. It is
// owner/admin-gated like the other history writes. The response echoes only the fields
// that were changed, so a title-only PATCH keeps its pre-D5 {id,title} shape.
// handlePatchSession updates a persistent session's display metadata — its title
// (A4), its pinned flag (D5) — and/or its role binding (design/22 §4.5). All three
// are metadata: unlike DELETE they are allowed on the fixed main session. A request
// carrying none of the fields answers 400, as do an empty/whitespace or over-long
// title and an unknown personaId; an unknown session id answers 404. Rebinding a
// role does not touch the stored conversation — the messages keep the role that
// spoke them (design/22 §7-5) — it only changes which role answers from the next
// turn on. It is owner/admin-gated like the other history writes. The response
// echoes only the fields that were changed, so a title-only PATCH keeps its pre-D5
// {id,title} shape.
func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
if s.history == nil {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
@@ -53,8 +60,8 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if req.Title == nil && req.Pinned == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title or pinned is required"))
if req.Title == nil && req.Pinned == nil && req.PersonaID == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title, pinned or personaId is required"))
return
}
resp := map[string]any{"id": id}
@@ -81,16 +88,43 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
}
resp["pinned"] = *req.Pinned
}
if req.PersonaID != nil {
personaID := strings.TrimSpace(*req.PersonaID)
if !s.requireKnownPersona(w, personaID) {
return
}
if err := s.setSessionPersona(r.Context(), id, personaID); err != nil {
s.writeSessionPatchError(w, err, id, "set session persona")
return
}
resp["personaId"] = personaID
}
writeJSON(w, http.StatusOK, resp)
}
// writeSessionPatchError maps a RenameSession/SetPinned failure to its response:
// ErrSessionNotFound → 404, anything else → 500 (logged under op).
// setSessionPersona rebinds a session's role through the store's optional
// role-writer. A store without it answers 501 rather than reporting a binding that
// was never written.
func (s *Server) setSessionPersona(ctx context.Context, sessionID, personaID string) error {
writer, ok := s.history.(sessionPersonaWriter)
if !ok {
return errPersonaBindingUnsupported
}
return writer.SetSessionPersona(ctx, sessionID, personaID)
}
// writeSessionPatchError maps a RenameSession/SetPinned/SetSessionPersona failure to
// its response: ErrSessionNotFound → 404, a store that cannot bind roles → 501,
// anything else → 500 (logged under op).
func (s *Server) writeSessionPatchError(w http.ResponseWriter, err error, id, op string) {
if errors.Is(err, history.ErrSessionNotFound) {
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
return
}
if errors.Is(err, errPersonaBindingUnsupported) {
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话角色绑定需要支持 personaId 的 HistoryStore", HTTPCode: http.StatusNotImplemented})
return
}
s.log.Error(op, "err", err, "session", id)
writeError(w, apperr.Wrap(apperr.ErrInternal, "session_failed", "failed to update session"))
}
+149
View File
@@ -0,0 +1,149 @@
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)
}
+5 -3
View File
@@ -161,10 +161,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
req.SessionID = "web"
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
// it binds the full-access env: memory read+write, unchanged behavior. The role
// comes from the session's binding (design/22 §4.5).
msgs := userChatMessages(req.Text, req.Image)
env := s.envResolver.owner(r.Context(), req.SessionID)
if s.hub.orchestrator != nil {
sr, res := s.hub.orchestrator.SubmitSync(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), req.SessionID, msgs, loop.OwnerEnv())
sr, res := s.hub.orchestrator.SubmitSync(withMode(withDeviceID(r.Context(), req.DeviceID), req.Mode), req.SessionID, msgs, env)
if !sr.Accepted {
if sr.PendingFull {
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
@@ -189,7 +191,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
}
// Legacy synchronous path when no orchestrator is wired (a hub built without
// one keeps the exact pre-design/15 reply contract).
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, msgs)
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), env), req.SessionID, msgs)
if err != nil {
s.log.Error("chat run", "err", err, "session", req.SessionID)
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
+9 -2
View File
@@ -84,8 +84,11 @@ type Server struct {
// (best-effort). See Deps.OwnerRegistrar.
ownerRegistrar OwnerRegistrar
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
// envResolver resolves the per-turn loop environment (its role) for a session.
// See persona_env.go / design/22 §4.5.
envResolver turnEnvResolver
log *slog.Logger
hub *hub
}
// New validates cfg and deps and returns a ready Server. Auth is required; the
@@ -128,6 +131,10 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
dataDir: deps.DataDir,
log: sourcedLog(deps.Log),
}
// One role resolver shared by the Server handlers and the hub, so every
// owner-facing entry point (REST, WS, voice) resolves the session's role the
// same way (design/22 §4.5) instead of each hardcoding loop.OwnerEnv().
s.envResolver = turnEnvResolver{history: deps.History, personas: deps.Personas, log: s.log}
s.hub = newHub(s)
s.hub.orchestrator = newOrchestrator(s)
s.hub.start()
@@ -128,11 +128,12 @@ type sessionState struct {
}
// pendingMsg is one queued user turn. It carries its own env so a resume runs
// under the same memory-isolation boundary the message arrived with, plus the
// client deviceID of the originating turn (design/16 §4.5) so the resumed
// assistant reply is tagged with the same client that sent the message, and the
// turn's behavior mode (design/16 §5.1) so the resumed reply is persisted under
// the same mode as the user message that started it.
// under the same memory-isolation boundary — and, since design/22, the same role
// (env.PersonaID) — the message arrived with, plus the client deviceID of the
// originating turn (design/16 §4.5) so the resumed assistant reply is tagged with
// the same client that sent the message, and the turn's behavior mode (design/16
// §5.1) so the resumed reply is persisted under the same mode as the user message
// that started it.
type pendingMsg struct {
messages []model.Message
env loop.SessionEnv
@@ -234,7 +235,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode)
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
}
@@ -248,14 +249,14 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
o.mu.Unlock()
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode)
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
if sync {
res, err := o.run(runCtx, sessionID, messages, env)
if err != nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err, deviceID, msgMode)
_ = o.completeTurn(sessionID, gen, res, err, deviceID, msgMode, env.PersonaID)
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
}
@@ -266,14 +267,16 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
// goTurn runs a single turn on a background goroutine and, when it finishes,
// hands the outcome to completeTurn so the orchestrator can resume the queue.
// deviceID tags the persistent assistant reply with the client that sent the turn
// (design/16 §4.5) and mode with the turn's behavior mode (design/16 §5.1).
// (design/16 §4.5), mode with the turn's behavior mode (design/16 §5.1) and env is
// the turn's resolved role/isolation environment (design/22 §4.5), so the reply is
// filed under the role that produced it.
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc, deviceID, mode string) {
defer cancel()
res, err := o.run(ctx, sessionID, messages, env)
if err != nil && ctx.Err() == nil {
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
}
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode)
_ = o.completeTurn(sessionID, gen, res, err, deviceID, mode, env.PersonaID)
}
// completeTurn runs after a turn settles (either it finished, errored, or was
@@ -282,10 +285,11 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
// message "remembers" the prior context instead of starting from a blank slate
// (design/15 §2.2: incremental, not full replay). A stale generation (an
// interrupt or a newer top-level turn superseded this one) is a no-op.
// deviceID is the originating turn's client id and mode its behavior mode, both
// used to tag the persisted reply (design/16 §4.5, §5.1).
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode string) error {
o.persistAssistantReply(sessionID, res, deviceID, mode)
// deviceID is the originating turn's client id, mode its behavior mode and
// personaID the role it spoke as, all used to tag the persisted reply
// (design/16 §4.5, §5.1; design/22 §7-5).
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
@@ -458,40 +462,3 @@ func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
}
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
// slice or another state's tail.
func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
if len(msgs) <= o.maxTail {
return append([]model.Message(nil), msgs...)
}
out := make([]model.Message, o.maxTail)
copy(out, msgs[len(msgs)-o.maxTail:])
return out
}
// messagesEmpty reports whether a turn carries no usable text or image, so the
// orchestrator does not mark a session busy on a vacuous message.
func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
return false
}
if c.Type == model.ContentTypeImage && c.ImageURL != "" {
return false
}
}
}
return true
}
// concatMessages returns a new slice combining a and b (never reusing a or b
// backing arrays) so the continuation tail is safe to hand to a concurrent run.
func concatMessages(a []model.Message, b ...model.Message) []model.Message {
out := make([]model.Message, 0, len(a)+len(b))
out = append(out, a...)
out = append(out, b...)
return out
}
+17 -14
View File
@@ -49,11 +49,13 @@ func modeFrom(ctx context.Context) string {
// history store so the raw user stream is durable before the turn resolves, then
// notifies the transport so every browser of the same account can append it
// without a manual refresh (design/16 §3.3, §4.2). deviceID tags the message with
// the sending client (design/16 §4.5) and mode is the behavior mode the turn was
// sent in (design/16 §5.1), falling back to modeChat when empty. It is a no-op
// when no history store is wired. Rejected turns (empty, pending-full) never
// reach it, so nothing vacuous is persisted.
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode string) {
// the sending client (design/16 §4.5), mode is the behavior mode the turn was
// sent in (design/16 §5.1), falling back to modeChat when empty, and personaID is
// the role the turn speaks as (design/22 §7-5) — the role of the moment, so a
// session that later switches roles keeps an accurate "who said this" stream. It
// is a no-op when no history store is wired. Rejected turns (empty, pending-full)
// never reach it, so nothing vacuous is persisted.
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) {
if o.history == nil {
return
}
@@ -65,7 +67,7 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
if text == "" && image == "" {
continue
}
full, err := o.history.AppendFull(ctx, history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID, Mode: modeOrDefault(mode)})
full, err := o.history.AppendFull(ctx, history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID})
if err != nil {
o.log.Error("orchestrator persist user", "err", err, "session", sessionID, "device_id", deviceID)
continue
@@ -76,19 +78,20 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
// persistAssistantReply writes the final assistant text of a settled turn to the
// history store, then notifies the transport (design/16 §3.3, §4.2). deviceID tags
// the reply with the client that sent the turn (design/16 §4.5) and mode is the
// the reply with the client that sent the turn (design/16 §4.5), mode is the
// behavior mode of that turn (design/16 §5.1), falling back to modeChat when
// empty. Mood is the persona's mood for that turn (design/01 §2 情绪状态机), taken
// off the loop result, so the stored/DTO/`message_persisted` mood is real data
// rather than a permanently empty column; a turn with no mood tracking stores an
// empty mood. It is a no-op when no history store is wired or the turn produced no
// text (interrupted/errored turns leave res nil). The write runs under a fresh
// empty, and personaID is the role that produced the reply (design/22 §7-5). Mood
// is the persona's mood for that turn (design/01 §2 情绪状态机), taken off the loop
// result, so the stored/DTO/`message_persisted` mood is real data rather than a
// permanently empty column; a turn with no mood tracking stores an empty mood. It
// is a no-op when no history store is wired or the turn produced no text
// (interrupted/errored turns leave res nil). The write runs under a fresh
// background context so a canceled turn context never aborts it.
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode string) {
func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, deviceID, mode, personaID string) {
if o.history == nil || res == nil || res.Text == "" {
return
}
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode)})
full, err := o.history.AppendFull(context.Background(), history.Message{SessionID: sessionID, Role: history.RoleAssistant, Content: res.Text, Mood: res.Mood, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID})
if err != nil {
o.log.Error("orchestrator persist assistant", "err", err, "session", sessionID, "device_id", deviceID)
return
+46
View File
@@ -0,0 +1,46 @@
package gateway
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
// This file owns the conversation-tail helpers of the session orchestrator
// (design/15 §2.2/§4): the bounded window that continuation resumes from and the
// emptiness check that keeps a vacuous turn from marking a session busy. They are
// pure functions over []model.Message, kept apart from the state machine so the
// orchestrator file stays small.
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
// slice or another state's tail.
func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
if len(msgs) <= o.maxTail {
return append([]model.Message(nil), msgs...)
}
out := make([]model.Message, o.maxTail)
copy(out, msgs[len(msgs)-o.maxTail:])
return out
}
// messagesEmpty reports whether a turn carries no usable text or image, so the
// orchestrator does not mark a session busy on a vacuous message.
func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
return false
}
if c.Type == model.ContentTypeImage && c.ImageURL != "" {
return false
}
}
}
return true
}
// concatMessages returns a new slice combining a and b (never reusing a or b
// backing arrays) so the continuation tail is safe to hand to a concurrent run.
func concatMessages(a []model.Message, b ...model.Message) []model.Message {
out := make([]model.Message, 0, len(a)+len(b))
out = append(out, a...)
out = append(out, b...)
return out
}
+4 -2
View File
@@ -156,8 +156,10 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) {
return
}
// The Web main session is the owner's private conversation, so it binds the
// full-access env: memory read+write, unchanged behavior.
res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
// full-access env: memory read+write, unchanged behavior. The role comes from the
// session's binding (design/22 §4.5).
env := h.envResolver.owner(ctx, req.SessionID)
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), req.SessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)})
if err != nil {
h.log.Warn("voice chat run", "err", err, "session", req.SessionID)
h.voiceError(c, "voice chat failed")
+10 -6
View File
@@ -60,12 +60,15 @@ type hub struct {
streaming bool
orchestrator *Orchestrator
history HistoryStore
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
// envResolver resolves each turn's role (design/22 §4.5); it mirrors the
// server's resolver so the WS path and the REST path never diverge.
envResolver turnEnvResolver
log *slog.Logger
upgrader websocket.Upgrader
origins map[string]bool
allowAll bool
cancel context.CancelFunc
streamSeq atomic.Uint64
}
// newHub builds a hub bound to the server's dependencies.
@@ -80,6 +83,7 @@ func newHub(s *Server) *hub {
voice: s.voice,
streaming: s.streaming,
history: s.history,
envResolver: s.envResolver,
log: s.log,
origins: origins,
allowAll: allowAll,
+7 -3
View File
@@ -58,17 +58,21 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
req.SessionID = "web"
}
// The Web main session is the owner's private conversation (JWT = owner), so
// it binds the full-access env: memory read+write, unchanged behavior.
// it binds the full-access env: memory read+write, unchanged behavior. The role
// comes from the session's binding (design/22 §4.5): a session switched to role Y
// answers as Y on the next turn, while an unbound/existing session keeps
// resolving to the global default.
msgs := userChatMessages(req.Text, req.Image)
// Thread the client's deviceID and behavior mode into the orchestrator via ctx
// so both the persisted user turn and the assistant reply are tagged
// (design/16 §4.5, §5.1).
ctx = withMode(withDeviceID(ctx, req.DeviceID), req.Mode)
env := h.envResolver.owner(ctx, req.SessionID)
if h.orchestrator != nil {
_ = h.orchestrator.Submit(ctx, req.SessionID, msgs, loop.OwnerEnv())
_ = h.orchestrator.Submit(ctx, req.SessionID, msgs, env)
return
}
_, _ = h.runTurn(ctx, req.SessionID, msgs, loop.OwnerEnv())
_, _ = h.runTurn(ctx, req.SessionID, msgs, env)
}
// runTurn executes one agent turn and broadcasts its output stream. It is the