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