From 0624d9ee36ee669b0389ae9fe28dc7fc06fca8bd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 20:12:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(session,runtime):=20=E5=A4=9A=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=B9=B6=E5=AD=98=E2=80=94=E6=8C=89=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=80=89=E8=A7=92=EF=BC=88=E8=BF=81=E7=A7=BB0011=20+=20?= =?UTF-8?q?=E6=8C=89=E5=9B=9E=E5=90=88=E8=A7=A3=E6=9E=90=20+=20agent?= =?UTF-8?q?=E6=B1=A0=20+=20=E6=8F=90=E5=8F=96=E5=99=A8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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) --- Docs/api-client.md | 24 +- cmd/cyrene/extractor.go | 11 +- cmd/cyrene/extractor_test.go | 17 +- cmd/cyrene/persona_agents.go | 68 ++++ cmd/cyrene/persona_emotion_test.go | 111 +++++++ cmd/cyrene/persona_prompt.go | 32 +- cmd/cyrene/wire.go | 14 +- internal/db/migrate_session_persona_test.go | 84 +++++ internal/db/migrate_test.go | 8 +- .../migrations/0011_session_persona.down.sql | 11 + .../db/migrations/0011_session_persona.up.sql | 33 ++ internal/runtime/history/list_after.go | 2 +- internal/runtime/history/message_ops.go | 4 +- internal/runtime/history/session_persona.go | 81 +++++ .../runtime/history/session_persona_test.go | 172 ++++++++++ internal/runtime/history/session_preview.go | 4 +- internal/runtime/history/session_read_test.go | 17 +- internal/runtime/history/store.go | 66 ++-- internal/runtime/loop/loop.go | 10 +- internal/runtime/loop/memory.go | 36 +- internal/runtime/loop/options.go | 36 +- internal/runtime/loop/persona.go | 86 +++++ internal/runtime/loop/persona_test.go | 277 +++++++++++++++ internal/runtime/loop/request.go | 43 +-- internal/runtime/loop/run.go | 2 +- internal/runtime/loop/sessionenv.go | 14 +- internal/runtime/loop/spanlog.go | 20 +- internal/runtime/loop/step.go | 8 +- internal/runtime/loop/stream.go | 27 +- internal/runtime/loop/turn.go | 8 +- internal/runtime/memory/extract.go | 7 +- internal/runtime/memory/llmextractor.go | 23 +- .../admin_write_plugins_memory_sessions.go | 11 +- internal/services/gateway/history.go | 70 +++- .../services/gateway/history_handler_test.go | 6 +- .../services/gateway/history_persona_test.go | 314 ++++++++++++++++++ internal/services/gateway/history_write.go | 64 +++- internal/services/gateway/persona_env.go | 149 +++++++++ internal/services/gateway/rest.go | 8 +- internal/services/gateway/server.go | 11 +- .../services/gateway/session_orchestrator.go | 69 +--- internal/services/gateway/session_persist.go | 31 +- internal/services/gateway/session_tail.go | 46 +++ internal/services/gateway/voice.go | 6 +- internal/services/gateway/ws_hub.go | 16 +- internal/services/gateway/ws_hub_turn.go | 10 +- 46 files changed, 1908 insertions(+), 259 deletions(-) create mode 100644 cmd/cyrene/persona_agents.go create mode 100644 cmd/cyrene/persona_emotion_test.go create mode 100644 internal/db/migrate_session_persona_test.go create mode 100644 internal/db/migrations/0011_session_persona.down.sql create mode 100644 internal/db/migrations/0011_session_persona.up.sql create mode 100644 internal/runtime/history/session_persona.go create mode 100644 internal/runtime/history/session_persona_test.go create mode 100644 internal/runtime/loop/persona.go create mode 100644 internal/runtime/loop/persona_test.go create mode 100644 internal/services/gateway/history_persona_test.go create mode 100644 internal/services/gateway/persona_env.go create mode 100644 internal/services/gateway/session_tail.go diff --git a/Docs/api-client.md b/Docs/api-client.md index 6dfe34e..1a092fb 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -26,13 +26,13 @@ | 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer ` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | | 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06)——**真视觉需配 `LLM_VISION_MODEL`/凭据,无凭据时降级为主模型直接看图或视觉·OCR 工具文本描述**;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | -| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 | -| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId}}`(`sessionId="web:"`);未注入 HistoryStore 回 `501 not_implemented` | -| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | +| 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`(**排序(D5):`pinned=true` 优先,其次主会话 `id="web"` 恒在前,再按 `createdAt` 升序**;次级 `web:`;`pinned` 恒返回 bool;**`unreadCount`(D4)恒返回整数**——该会话中 `createdAt >` 已读游标的消息条数(0 = 无未读),与 `lastMessage` **同一条 SQL 派生**、无 N+1;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略;**`personaId`(v0.42,可选)**=该会话绑定的角色 id(design/22 §2/§4.5),**空/缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像);未注入时退化回渠道会话列表 | +| 5 | `/api/v1/sessions` | POST | JWT(owner/admin) | ✅ | **新建次级会话** → `201 {data:{sessionId,personaId?}}`(`sessionId="web:"`)。body **可选**(可完全省略或空 body):`{personaId?}`(v0.42)在创建时就把新会话绑到该角色,等价于随后 PATCH 一次(design/22 §2:渠道级只作"新会话的初始角色提供者");`personaId` 为**未知角色**回 `400 bad_request`;省略/空 = 不绑(`persona_id=''`,继承全局默认);**`personaId` 非空时响应回显它**(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 `501 not_implemented`;注入了**不支持角色绑定**的 HistoryStore 且显式传 `personaId` 时也回 `501`(不假装写入) | +| 6 | `/api/v1/sessions/{id}/messages` | GET | JWT(owner/admin) | ✅ | **热分页拉历史**:`?limit=&before=&platform=&after=`(A5)默认 `limit=50`(上限 200)。**`before`**=上一页最旧 `createdAt`(毫秒),按时间**倒序**;**`after`**=<`createdAt` 毫秒>,按时间**升序**返回 `created_at > after` 的消息(**正向增量**,客户端把上次看到的 `createdAt` 当游标补断线期间漏的,解决重连只回放 50 条的缺口);**`after` 与 `before` 互斥**,同传回 `400`;负数/非数字回 `400`。`platform` 为**可选**来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传**本平台**(如 `telegram`)只拉本渠道消息;`deviceId` 为**客户端具体端标识**;`senderName` 为**第三方发送者昵称**;**`personaId`(v0.42,可选)**=**该消息说话时的角色 id**(design/22 §7-5:会话可在保留上下文的前提下换角色,故每条消息都记"谁说的";assistant 消息 = 生成它的角色,user 消息 = 当回合角色;**空 = 全局默认角色**,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;**`q`**(D2)=**会话内内容搜索**(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义为字面量),仍按时间**倒序**、`before`/`limit` 同分页语义;`q` 与 `after` **互斥**(同传 `400`),空 `q` 视为未传(走普通分页),搜索**严格限定在 `{id}` 会话内**(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 `limit`/`before`/`after` 回 `400`;未注入 HistoryStore 回 `501` | | 6a | `/api/v1/messages/{id}` | PATCH | JWT(owner/admin) | ✅ | **编辑单条消息**(D3):body `{content}`(非空、≤4000 rune,空/纯空白/超长回 `400`)→ **`{data:<消息DTO>}`**(`data` **直接就是**更新后的完整消息 DTO,含 `id/sessionId/role/content/.../createdAt`,**无 `message` 包一层**;**只改 `content`**,role/时间戳/平台保持不变);消息不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_edited`**(§3.8,账号其它端免刷新同步)。**重发不单独设端点**——客户端用「编辑后的内容再 `POST /chat`」即可(避免重复轮次语义) | | 6b | `/api/v1/messages/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除单条消息**(D3)→ `{data:{deleted:}}`;不存在回 `404 not_found`;未注入 HistoryStore 回 `501`。**成功后广播下行 `message_deleted`**(§3.8,账号其它端按 `id` 移除) | | 7 | `/api/v1/sessions/{id}` | DELETE | JWT(owner/admin) | ✅ | **删除次级会话及其消息** → `{data:{deleted:}}`;主会话不可删回 `403 forbidden`;未注入 HistoryStore 回 `501` | -| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶):body `{title?,pinned?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可)。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / 两者都改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶但不可删;未注入 HistoryStore 回 `501`。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 | +| 8 | `/api/v1/sessions/{id}` | PATCH | JWT(owner/admin) | ✅ | **会话元数据更新**(A4 改名 + D5 置顶 + v0.42 选角):body `{title?,pinned?,personaId?}`(**至少一个字段**,都缺回 `400`)。`title` trim、≤80 rune,空/超长回 `400`;`pinned` 为 bool(置顶/取消,主会话与次级会话均可);**`personaId`**(v0.42)重新绑定该会话的角色——**显式 `""` = 解绑**(回到"继承全局默认"),未知角色 id 回 `400`,主会话与次级会话均可改(design/22 §4.5);**改角色不动已存会话内容**:历史消息仍带各自"说话时的角色"(`chat_messages.persona_id`),只有**下一回合**起用新角色的提示词/记忆域/情绪/思考预算。响应只回显**本次变更**的字段:`{data:{id,title?}}` / `{data:{id,pinned?}}` / `{data:{id,personaId?}}` / 多项同改则都有(**title-only 保持 A4 原 `{id,title}` 形状**);不存在回 `404`;主会话(`id="web"`)可改名/置顶/选角但不可删;未注入 HistoryStore 回 `501`(不支持角色绑定的 HistoryStore 在 `personaId` 路径也回 `501`)。另:会话**首条用户消息到达时自动命名** title(取前 20 rune 折叠空白;纯空白回落 `主会话`/`次级会话`),列表不再空名 | | 8a | `/api/v1/sessions/{id}/read` | POST | JWT(owner/admin) | ✅ | **推进已读游标**(D4 / Android 协同 B3):body `{before?}`——`before` 为**可选**的毫秒 Unix 时间戳,**缺省(或空 body)= 现在**,即「我看到的都算已读」。→ `200 {data:{sessionId,readAt}}`(`readAt` 为推进后的游标毫秒)。**判定**:`createdAt <= readAt` 的消息算已读(列表 `unreadCount` 只数 `createdAt > readAt` 的);**语义**:**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。`before` 为 `0`/负数/非数字、或 body 不是 JSON 对象 → `400`;未知会话(含隔离 `sess-xxx`,不在 `chat_sessions`)→ `404`;未注入 HistoryStore → `501 not_implemented`。**成功后广播下行 `session_read`**(§3.9)让同账号其它端清红点;**失败路径不广播**。**升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 | | 9 | `/api/v1/personas` | GET | JWT | ✅ | `{data:[persona...]}` | | 10 | `/api/v1/audio/transcribe` | POST | JWT(owner) | ✅ | **语音→文本**:body `{audio:"",format?,deviceId?}` → `{data:{text, provider?}}`(走后端 STT,百炼 `qwen-audio-3.0-asr-flash`;**当结果为空文本/silent 降级时响应体带 `provider`**(如 `"silent"`,非空文本省略),客户端据此提示「没识别到声音」) | @@ -110,7 +110,7 @@ - Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。 - **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。 -> **契约版本 `v0.41`**(新增可选字段,向后兼容;本条标记此前停留在 `v0.39`,本轮随人格 `thinking` 字段一并对齐到最新版本号)。 +> **契约版本 `v0.42`**(多角色并存 + 按会话选角:**新增两个可选字段(会话/消息 `personaId`)+ 两个可选请求字段(`POST /sessions` body、`PATCH /sessions/{id}` 的 `personaId`)**,向后兼容)。 ## 2. REST 端点(管理,`/api/v1/admin/*`) @@ -142,7 +142,7 @@ | # | 端点 | 方法 | 状态 | 说明 | |---|------|------|:---:|------| | 1 | `/api/v1/admin/personas` | GET | ✅ | 人格列表 | -| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图 | +| 2 | `/api/v1/admin/sessions` | GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。**v0.42 起每项含可选 `personaId`**(与会话级选角同源,见 §1 行 4) | | 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) | | 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) | | 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) | @@ -201,7 +201,7 @@ - `POST /admin/plugins/install`(**owner-only + 审计 `plugin.install`**)body `{kind:"mcp",name,config:{transport:"stdio|http|sse",endpoint?|command?,args?,apiKey?,env?}}` → `200 {data:{installed:true,name,kind:"mcp",transport,toolCount}}`。**v1 只装 MCP server**(单二进制无法热装 Go 插件):真实 connect+discover,把发现的工具**热注册**进 capability registry(下次模型请求即注入,无需重启)→ 落盘 `plugins.json`(本地、gitignore、0600,`apiKey` 写入但**永不回显/不落日志**)→ 持有 live server 供后续刷新/卸载。**同名重复安装 = 刷新**(先卸旧再装新,失败回滚旧)。校验:`name` 必填、`kind` 非 `mcp` 回 `400`、transport 非法/`stdio` 缺 `command`/`http|sse` 缺 `endpoint` 回 `400`;**连接/发现/注册失败回 `502 plugin_install_failed` 且不落盘**(诚实,不静默装一半)。未注入 plugin manager 回 `501 not_implemented`。 - `POST /admin/plugins/uninstall`(**owner-only + 审计 `plugin.uninstall`**)body `{name}` → `200 {data:{name,uninstalled:true,kind:"mcp"}}`(已装 MCP:`Unregister` 移除其工具 + close + 删 `plugins.json` 条目;**非 MCP/普通能力**走原 `registry.Remove`,返回 `{data:{name,uninstalled:true}}`);未命中回 `404`。 - `POST /admin/memories` body `{personaId?,content,scope?}` → `201 {data:{id,scope,text}}`。**手动添加记忆**(owner 私聊直写);`scope` ∈ `user|persona|project`,缺省 `persona`(与「记住…」同路径);无效非空 `scope` 回 `400`;未注入 `memory.Store` 回 `501`。 -- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。 +- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。**`personaId` 省略时(v0.42 起)回落到库里注册的默认角色**(`default` 标记的那个 character → 其 persona),**不再写死 `"cyrene"`**;若既未传 `personaId` 又没有任何默认角色 → `400 bad_request`(诚实报错,而不是拿一个不可能命中的 id 去查)。 - `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。 **系统设置契约**(`POST /api/v1/admin/system/*`,均 **owner-only**,审计;成功 `{data}` / 失败 `{error:{code,message}}`): @@ -239,7 +239,7 @@ - **握手**: `ws://:8080/ws/chat?token=` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。 - **下行(服务端→客户端)**: 经 bus 广播 `{type:"", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤;**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。 - > **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)。 + > **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离,不泄给非 owner;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。 ### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`) - **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.update` 过滤到 `sessionId === activeSessionId` 的会话,忽略非激活会话的帧(多会话并发流式不再串话)。 @@ -268,7 +268,7 @@ ### 3.3 消息持久化增量事件(`message_persisted`,design/16) - **触发**:每一条 user / assistant 消息**落库后**由编排层广播——Web 主会话路径走 `persistUserMessages`/`persistAssistantReply`,**IM 渠道主人私聊**(design/16 §4.4)由 channelbridge 把消息**落主会话 `web`**(带 `platform` 标记)后复用**同一** `message_persisted` 广播(同一 hub 的 `BroadcastPersisted`),保证 Web 端实时镜像 IM 消息。 -- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);`thinking` 已剥离(design/16 §6)。 +- **载荷**:`{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}`——`role` ∈ `user|assistant`;`id` 为**后端生成的消息 id**(客户端据此去重);`platform` 为**来源平台**(`web` 或渠道如 `telegram`/`weixin-ilink`/`qq-obv11`);`deviceId` 为**具体端/客户端标识**(design/16 §4.5,如 `web:chrome:a1b2c3` / `weixin-ilink:conf0:wx_uid`);`senderName` 为**第三方发送者昵称**(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,`omitempty`);**`personaId`(v0.42,`omitempty`)=该消息说话时的角色 id**(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;`thinking` 已剥离(design/16 §6)。 - **注:群聊/陌生人(非主人)消息**只在**该 IM 适配器日志界面**以消息流显示,**不**经 `message_persisted` 进 Web 主会话(design/16 §4.6 §3-4);该日志流由 `GET /api/v1/admin/channels/{platform}/messages` 拉取,落库后另经 `channel_persisted`(§3.4)实时推送。 - **客户端处理**:仅当 `data.message.sessionId === activeSessionId`(激活会话)时处理;按 `id` upsert——本端已预建的**同 role 乐观气泡**(本地 `uid` 无后端 `id`)被**归一**(`uid` → 后端 `id`),异端无本地气泡则直接追加,两者最终收敛为**同一条 `id` 相同的消息**,「A 发消息、A 与 B 都无需刷新且各出现一次」。 @@ -325,8 +325,9 @@ 3. **带端标识**(可选但推荐):每条消息/请求可带 `deviceId`(见 §4.3),让她知道你从哪个端来。 ### 4.2 会话与消息用法(聊天) -- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。 -- **改名 / 置顶**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)或 `{pinned?:bool}`(至少一个字段)→ `{data:{id,title?,pinned?}}`(只回显变更字段)。 +- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:` 由 `POST /api/v1/sessions` 新建(临时小事/专业问题,独立上下文回溯)。**`personaId`(v0.42)=该会话绑定的角色**;空/缺省 = **继承全局默认角色**,客户端渲染激活会话的名字/头像用它(后端唯一真源是 `chat_sessions.persona_id`,localStorage 之类只作缓存)。 +- **选角 / 换角色(v0.42)**:`POST /api/v1/sessions` body `{personaId?}` 在**新建时**就绑角色;已存在的会话用 `PATCH /api/v1/sessions/{id}` body `{personaId}` 重新绑定(显式 `""` 解绑回"跟随默认")。**换角色保留上下文**:历史消息逐条带自己的 `personaId`,只有**下一回合**起提示词/记忆域/情绪/思考预算换成新角色(design/22 §7-5)。**记忆按角色隔离**:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍**只读**、且强制默认角色。 +- **改名 / 置顶 / 选角**:`PATCH /api/v1/sessions/{id}` body `{title?}`(≤80 rune)或 `{pinned?:bool}` 或 `{personaId?:string}`(至少一个字段)→ `{data:{id,title?,pinned?,personaId?}}`(只回显变更字段)。 - **未读 / 已读游标(D4)**:列表项的 **`unreadCount`** 直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用 **`POST /api/v1/sessions/{id}/read`**:body `{"before":<毫秒>}`(如「本次已渲染的最新一条的 `createdAt`」),或**省略 body** = 标记到**现在**。后端**只前进**地推进该会话**唯一**游标(单 owner 账号:读一处 = 所有端已读),并广播 `session_read`(§3.9)——同账号其它端**免刷新**清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。 - **拉取激活会话历史**:`GET /api/v1/sessions/{id}/messages?limit=50&before=&platform=&q=`。 - **热分页**:`limit` 默认 50(上限 200),`before` = 当前最旧一条的 `createdAt`(毫秒),向上翻页。`platform` 可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。 @@ -353,6 +354,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.42`(**多角色并存 + 按会话选角**,对客户端**新增可选字段/可选请求字段**,向后兼容;design/22 批次 1 = 后端会话级绑定):① **`chat_sessions` 新增 `persona_id TEXT NOT NULL DEFAULT ''`、`chat_messages` 新增 `persona_id TEXT NOT NULL DEFAULT ''`**(同一迁移 **0011**,down 可逆,**不回填**)。`''` **= 继承全局默认角色**(`owner.yaml:defaultPersona`),所以**既有一切行升级后行为逐字节不变**,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。② **`GET /api/v1/sessions` 每项新增可选 `personaId`**(=会话绑定角色);**消息 DTO(REST `GET /sessions/{id}/messages`、WS `history` 回放、`message_persisted`/`message_edited` 广播)新增可选 `personaId`**(=**说话时的角色**,design/22 §7-5)。③ **`POST /api/v1/sessions` body 新增可选 `{personaId}`**(创建即绑定;未知角色 `400`;响应仅在非空时回显 `personaId`,故**省略时响应形状与 v0.41 完全一致**);**`PATCH /api/v1/sessions/{id}` body 新增可选 `{personaId}`**(重新绑定,**显式 `""` 解绑**,未知角色 `400`;只回显变更字段)。④ **回合语义**:角色决定**系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算**四者**同源**解析(design/22 §4.2),换角色**保留上下文**、**不动已存消息**,**下一回合**起生效;**记忆口径 = 保持 persona 隔离**(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ **请求级覆盖(每次发送临时指定角色)本批未实现**(design/22 §2 裁决规则 1 仍待批次 2/后续);**渠道级绑定(IM 账号/群默认角色)本批未实现**(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ **契约内新增内部读接口**:网关经窄接口类型断言读取 `history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor`(注入的 HistoryStore **不支持**角色绑定时,`personaId` 写路径回 `501` 而非假成功)。 - `v0.41`(**人格 `thinking` 配置 + 推理旋钮按 provider 映射**,对客户端**新增一个可选请求字段**,向后兼容):① `POST /admin/personas` / `PUT /admin/personas/{id}` body 新增可选 **`thinking`** 对象 `{budgetTokens?,effort?,showToUser?,mode?}`(design/01 §2.1),原样写入并持久化到 `configs/personas/.yaml` 的 `thinking` 节;非法 `mode`/负预算回 **`400 bad_request`**;**`PUT` 省略该对象时保留已存 `thinking`**(旧面板/脚本不会抹掉它);该字段只描述**呈现/资源**,不构成能力边界。② **`showToUser` 语义**:**缺省 = 照常把 `thinking.*` 流帧推给客户端**(现行为),显式 `false` = 仅落库/剥离语义不变、**不推帧**(展示与落库解耦)。③ **服务端推理映射**(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置 `budgetTokens`/`mode: on` 时发 `thinking:{type:"enabled",budget_tokens:N}`,并**保证 `budget_tokens + 正文留量 ≤ max_tokens`**(不足则抬高 `max_tokens` 并记 WARN,绝不把正文吃空);OpenAI 兼容系按**识别到的厂商白名单**校验 `reasoning_effort`(OpenAI 系 `none…max`、DeepSeek 仅 `low/high/max`),非法值**降级到最近一档并记 WARN**;**未知 provider/未识别模型、以及 Qwen 的 `enable_thinking` 硬开关(协议无统一 wire 形状)** 一律记 **WARN(含 provider/字段/取值)并保持请求不变**,绝不静默无效。详见 design/04 §3.2。 - `v0.40`(**`viewer` 角色真正接线:只读监控档**,对客户端**收紧/放宽若干 admin 读的鉴权**,无端点/字段形状变更):此前 `viewer` 只是常量(`internal/auth` 的 `RoleViewer` 注释写着「只读 dashboard/logs/monitoring」)而**从未接线**——管理侧 24 条 GET 全走 owner/admin,`viewer` 一条都读不到(**全 403**)。本版新增第四道门 `viewerAuth = owner+admin+viewer`,并把 **12 条只读监控 GET** 从 owner/admin 放宽到 owner/admin/**viewer**:`GET /admin/personas`、`/admin/sessions`、`/admin/plugins`、`/admin/plugins/installed`、`/admin/channels`、`/admin/iot`、`/admin/automations`、`/admin/rag`、`/admin/observability/metrics`、`/admin/observability/trace`、`/admin/observability/logs`、`/admin/notifications`。**明确不放给 viewer**(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或 `admin` 也不给):`GET /admin/memories`、`/admin/llm`、`/admin/users`、`/admin/backthink`、`/admin/observability/audit`、`/admin/channels/messages`、`/admin/channels/{platform}/messages`、`/admin/sessions/{id}/export`、`POST /admin/rag/query`(这 9 条 owner/admin)以及其余 **35 条 owner-only**(全部写操作 + `login-qr`/`login-status`/`view`/`friends`/渠道连接与配置/`plugins/*`/`users/*`/`system/*`)。**匿名**:任意 admin 路由仍 `401`(fail-closed)。**向后兼容**:`owner`/`admin` 语义不变(放行集合是超集),仅 `viewer` 的可见面从「全 403」变为 12 条只读监控;不依赖 `viewer` 的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。 - `v0.39`(**微信 iLink 会话被拒可见化 + 不再假 OK**,对客户端**新增两个可选字段、一处错误码**,向后兼容):① **`GET /api/v1/admin/channels` 每平台行新增可选 `needsRescan`(bool,`omitempty`)+ `lastError`(string,`omitempty`)**——仅当适配器**真的**要求重新授权时出现(今天只有微信 iLink 返回 `errcode=-14` 这一种),取值来自适配器新增的**可选能力接口** `RescanRequired() (bool, string)`,网关**经类型断言**读取(主 `PlatformAdapter` 契约**不变**;未实现该能力的平台字段**缺省不出现**)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅 `connected:false`。② **`POST /admin/channels/open`** 对「iLink 已拒绝该会话」回 **`409 session_rejected`**(message 明确要求用「扫码登录」重新绑定),**不再**吞掉错误回 200;`409 needs_login`(尚无 token,v0.8 起)不变。③ **适配器行为**:iLink `errcode=-14` 进入**显式被拒态**——`IsConnected()` 恒 `false`;`Connect()` 返回被拒错误(**不再**对被拒会话报成功);`HealthCheck` 区分「会话被拒」与「未连接」;并以 **`ERROR`** 级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带 `bot_id` 等非敏感标识,**token 永不入日志**)。被拒态在读循环与发送两条路径**都会**置位,并仅在**扫码登录拿到新 token** 或**配置换了新 token**(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间 `login-status` 报 **`failed` + 被拒原因**(**不再**假报 `confirmed`,否则面板会把已作废凭据当成功重新落盘);`login-qr` 仍可正常出码(**不**回 `already_logged_in`,否则重新扫码这条路被堵死);`get_bot_qrcode` 不再携带已被拒的 token(否则服务端以「已绑定」应答、**不**发新凭据)。**向后兼容**:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。**语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用)**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用),`errcode=-14` 表示 **iLink 侧拒绝/作废了该会话凭据**,**不是时间性过期**。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。 diff --git a/cmd/cyrene/extractor.go b/cmd/cyrene/extractor.go index 671d7a5..aad0095 100644 --- a/cmd/cyrene/extractor.go +++ b/cmd/cyrene/extractor.go @@ -23,15 +23,20 @@ const memoryOwnerRoleID = "owner" // records the owner's explicit "记住 X" directives instead of failing a model // call on every turn. // +// The extractor deliberately holds NO persona: one process now answers as several +// roles (design/22 §4.2), so a persona baked in here would file every extracted +// memory under that one role. The loop stamps each item with the role that +// produced the turn when it persists it (loop.persistMemories). +// // The loop detaches the LLM extractor to a background goroutine (ExtractAsync); // see internal/runtime/loop/memory.go. -func (a *app) buildMemoryExtractor(personaID string) memory.Extractor { +func (a *app) buildMemoryExtractor() memory.Extractor { if llmGroupUsable(a.llm) { a.log.Info("memory extractor: llm", "model", a.llm.Model(), "provider", a.llm.Provider()) - return memory.NewLLMExtractor(a.llm, personaID, memoryOwnerRoleID) + return memory.NewLLMExtractor(a.llm, "", memoryOwnerRoleID) } a.log.Warn("memory extractor: manual (no usable llm model group)") - return memory.NewManualExtractor(personaID, memoryOwnerRoleID) + return memory.NewManualExtractor("", memoryOwnerRoleID) } // llmGroupUsable reports whether the active model group of the runtime stack can diff --git a/cmd/cyrene/extractor_test.go b/cmd/cyrene/extractor_test.go index dfa9023..4eae486 100644 --- a/cmd/cyrene/extractor_test.go +++ b/cmd/cyrene/extractor_test.go @@ -18,7 +18,10 @@ func testRuntimeLLM(t *testing.T, env config.LLMConfig) *modelpkg.RuntimeLLM { } // TestBuildMemoryExtractor_LLMWhenCredentials proves a configured provider group -// selects the LLM extractor (design/03 §1). +// selects the LLM extractor (design/03 §1). The extractor is role-agnostic +// (design/22 §4.2): it is built without a persona and the loop stamps the turn's +// role onto each extracted item, so multi-role extraction cannot be filed under +// the default role. func TestBuildMemoryExtractor_LLMWhenCredentials(t *testing.T) { t.Parallel() a := &app{ @@ -29,8 +32,8 @@ func TestBuildMemoryExtractor_LLMWhenCredentials(t *testing.T) { Model: "gpt-4o-mini", }), } - if _, ok := a.buildMemoryExtractor("persona-a").(*memory.LLMExtractor); !ok { - t.Fatalf("extractor = %T, want *memory.LLMExtractor (credentials configured)", a.buildMemoryExtractor("persona-a")) + if _, ok := a.buildMemoryExtractor().(*memory.LLMExtractor); !ok { + t.Fatalf("extractor = %T, want *memory.LLMExtractor (credentials configured)", a.buildMemoryExtractor()) } } @@ -40,13 +43,13 @@ func TestBuildMemoryExtractor_LLMWhenCredentials(t *testing.T) { func TestBuildMemoryExtractor_ManualWhenNoCredentials(t *testing.T) { t.Parallel() a := &app{log: testLog(), llm: testRuntimeLLM(t, config.LLMConfig{})} - if _, ok := a.buildMemoryExtractor("persona-a").(*memory.ManualExtractor); !ok { - t.Fatalf("extractor = %T, want *memory.ManualExtractor (no usable llm group)", a.buildMemoryExtractor("persona-a")) + if _, ok := a.buildMemoryExtractor().(*memory.ManualExtractor); !ok { + t.Fatalf("extractor = %T, want *memory.ManualExtractor (no usable llm group)", a.buildMemoryExtractor()) } bare := &app{log: testLog()} - if _, ok := bare.buildMemoryExtractor("persona-a").(*memory.ManualExtractor); !ok { - t.Fatalf("extractor = %T, want *memory.ManualExtractor (nil provider stack)", bare.buildMemoryExtractor("persona-a")) + if _, ok := bare.buildMemoryExtractor().(*memory.ManualExtractor); !ok { + t.Fatalf("extractor = %T, want *memory.ManualExtractor (nil provider stack)", bare.buildMemoryExtractor()) } } diff --git a/cmd/cyrene/persona_agents.go b/cmd/cyrene/persona_agents.go new file mode 100644 index 0000000..292bf65 --- /dev/null +++ b/cmd/cyrene/persona_agents.go @@ -0,0 +1,68 @@ +package main + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" +) + +// This file builds the per-role agent pool (design/22 §4.2 方案 A). An +// agent.Agent's instructions are immutable after construction, so "the session's +// role decides the system prompt" is implemented as one agent per role, all +// sharing the same dispatcher, guardrail pipeline, model adapter and options — +// only the instruction string differs. The loop picks the pool entry for the +// turn's role on every step (internal/runtime/loop/persona.go), so a role switch +// takes effect on the next turn without rebuilding the loop. +// +// Requirements from design/22 §5: prompt, memory, emotion and thinking must all +// be resolved from the same role. The pool owns the prompt; the loop owns the +// other three (personaFor / knobsFor), so a turn can never mix two roles. + +// personaAgentPool returns the per-role agent pool plus the agent to run turns +// with: the pooled agent for the default role when the pool has one, otherwise the +// caller's fallback agent, so a deployment with no registered character keeps +// exactly the pre-multi-character single agent. +func (a *app) personaAgentPool(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline, fallback *agent.Agent) (map[string]*agent.Agent, *agent.Agent) { + pool := a.buildPersonaAgents(llm, dispatcher, guards) + if pooled, ok := pool[a.defaultPersonaID()]; ok { + return pool, pooled + } + return pool, fallback +} + +// buildPersonaAgents returns the role → agent pool: every registered character's +// persona gets an agent carrying that persona's system instruction +// (identityInstructionFor + the personality/values/speechStyle/catchphrase/ +// background sections, design/01 §2.2). It returns an empty map when no persona is +// registered, so the loop keeps its single default agent. +// +// Only personas reachable through a registered character are pooled: a character +// is the selectable unit (1 character ↔ 1 persona), so a persona with no character +// can never be bound to a session. A session bound to an unknown id therefore +// falls back to the default agent, and its memory scope still follows the bound +// id — a stale binding degrades to the default prompt rather than failing the +// turn. +func (a *app) buildPersonaAgents(llm *model.RuntimeLLM, dispatcher *tool.Dispatcher, guards *guardrail.Pipeline) map[string]*agent.Agent { + log := logger.WithSource(a.log, "cmd/cyrene") + pool := make(map[string]*agent.Agent, a.personas.CountPersonas()) + for _, c := range a.personas.List() { + if _, ok := pool[c.PersonaID]; ok { + continue + } + p, ok := a.personas.Persona(c.PersonaID) + if !ok || p == nil { + continue + } + ag, err := agent.New(instructionsFromPersona(p), dispatcher, guards, + agent.WithModel(llm, llm), + agent.WithOptions(agent.Options{MaxRounds: defaultMaxRounds, Purpose: "chat"})) + if err != nil { + log.Warn("persona agent skipped", "persona", c.PersonaID, "error", err) + continue + } + pool[c.PersonaID] = ag + } + return pool +} diff --git a/cmd/cyrene/persona_emotion_test.go b/cmd/cyrene/persona_emotion_test.go new file mode 100644 index 0000000..9c72b37 --- /dev/null +++ b/cmd/cyrene/persona_emotion_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" + "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/loop" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// This file locks design/22 acceptance 4 at the composition-root level: with two +// roles registered, a turn of role Y must advance ONLY Y's emotion machine and +// affection (design/01 §2: 情绪/好感度 follow the persona), and the `mood` frame it +// broadcasts must name Y — never the default role. The turn's role reaches the hook +// through the loop's per-turn SessionEnv resolution (loop.TurnEnd.PersonaID), so a +// regression to a construction-time persona shows up as the wrong machine moving. + +// testTwoPersonaApp registers two personas (each with its own emotion machine and +// affection curve) and wires the composition root's emotion observer exactly as +// wire.go does. +func testTwoPersonaApp(t *testing.T) (*app, *fakeMoodEmitter) { + t.Helper() + reg := personareg.New() + for _, p := range []*loader.Persona{ + { + ID: "xilian", + Name: "昔涟", + Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5}, + Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10}, + }, + { + ID: "yuki", + Name: "由纪", + Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5}, + Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5}, + }, + } { + 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: %v", err) + } + if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "由纪", PersonaID: "yuki"}); err != nil { + t.Fatalf("RegisterCharacter: %v", err) + } + + sink := &fakeMoodEmitter{} + a := &app{log: testLog(), personas: reg, moodSink: sink} + a.emotion = a.buildEmotionObserver() + return a, sink +} + +// TestTurnEndMovesOnlyTheTurnPersona is acceptance 4: a Y turn moves Y's emotion and +// affection and leaves the default role's untouched, and the mood broadcast carries +// Y's id (so the frontend's 心情 badge is the active role's, not a global one). +func TestTurnEndMovesOnlyTheTurnPersona(t *testing.T) { + t.Parallel() + a, sink := testTwoPersonaApp(t) + ag, err := agent.New("你是由纪。", nil, nil, agent.WithModel(moodAdapter{reply: "由纪在呢~"}, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + l := loop.New(loop.Options{ + Agent: ag, + Agents: map[string]*agent.Agent{"yuki": ag}, + PersonaID: "xilian", // the configured default role is NOT the turn's role + OnTurnEnd: a.onTurnEnd, + }) + + env := loop.OwnerEnv() + env.PersonaID = "yuki" + res, err := l.Run(loop.WithSessionEnv(context.Background(), env), "web", []model.Message{model.TextMessage(model.RoleUser, "好开心呀,最喜欢你了")}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.Mood == "" { + t.Fatal("Result.Mood is empty, want Y's mood for the turn") + } + + yukiMachine, yukiAffection, ok := a.personas.Emotions().Get("yuki") + if !ok { + t.Fatal("persona yuki has no emotion machine") + } + if yukiMachine.Current() == emotion.Calm { + t.Fatalf("yuki machine state = %s, want it moved by her own turn", yukiMachine.Current()) + } + if yukiAffection.Value() != 25 { + t.Fatalf("yuki affection = %d, want 25 (20 + her own step 5)", yukiAffection.Value()) + } + + xilianMachine, xilianAffection, ok := a.personas.Emotions().Get("xilian") + if !ok { + t.Fatal("persona xilian has no emotion machine") + } + if xilianMachine.Current() != emotion.Calm { + t.Fatalf("default role machine = %s, want it untouched (calm)", xilianMachine.Current()) + } + if xilianAffection.Value() != 50 { + t.Fatalf("default role affection = %d, want it untouched (50)", xilianAffection.Value()) + } + + if sink.calls != 1 || sink.personaID != "yuki" { + t.Fatalf("mood broadcast = %+v, want exactly one frame naming yuki", sink) + } +} diff --git a/cmd/cyrene/persona_prompt.go b/cmd/cyrene/persona_prompt.go index 394c600..3f868b8 100644 --- a/cmd/cyrene/persona_prompt.go +++ b/cmd/cyrene/persona_prompt.go @@ -4,6 +4,7 @@ import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) @@ -106,7 +107,9 @@ func bulletList(items []string) string { // defaultThinkingOptions returns the reasoning knobs the loop should inject for // the default persona: the thinking directive, the reasoning_effort value and the -// thinking-broadcast switch. +// thinking-broadcast switch. It is the default half of the per-role resolution +// the loop performs every turn (Options.PersonaKnobs, see personaKnobs below), so +// the configured default and the per-turn lookup can never drift apart. // // The thinking directive is non-nil only when the persona configures something // that affects the request (a budget, an effort, or an explicit on/off mode). @@ -115,12 +118,35 @@ func bulletList(items []string) string { // effect. All three are zero when no persona is registered, which leaves the // loop's pre-existing behavior untouched. func (a *app) defaultThinkingOptions() (*model.Thinking, string, *bool) { - p, ok := a.defaultPersona() + knobs, ok := a.personaKnobs(a.defaultPersonaID()) if !ok { return nil, "", nil } + return knobs.Thinking, knobs.ReasoningEffort, knobs.ThinkingShowToUser +} + +// personaKnobs resolves a role's reasoning knobs for the loop +// (loop.Options.PersonaKnobs, design/22 §4.2): the loop asks for the role of the +// turn it is about to build a request for, so a session bound to role Y runs with +// Y's thinking budget / effort / broadcast switch instead of the default role's. +// Resolving from the role id rather than pushing a value into every caller's +// SessionEnv means every path — Web REST/WS/voice, IM, backthink, sub-agents — +// gets the right knobs from one input. ok=false means "unknown role", and the loop +// keeps its configured defaults. +func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) { + if personaID == "" { + return loop.LoopKnobs{}, false + } + p, ok := a.personas.Persona(personaID) + if !ok || p == nil { + return loop.LoopKnobs{}, false + } thinking, effort := personaThinking(p) - return thinking, effort, p.Thinking.ShowToUser + return loop.LoopKnobs{ + Thinking: thinking, + ReasoningEffort: effort, + ThinkingShowToUser: p.Thinking.ShowToUser, + }, true } // personaThinking maps the persona's `thinking` section onto the model-layer diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 90d7dd7..182ef2e 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -363,6 +363,9 @@ func (a *app) wireRuntime() error { personaID := a.defaultPersonaID() store := a.memStore + // One agent per role, all sharing the dispatcher/guards/adapter above, so a session + // can answer as any role (design/22 §4.2 方案 A); the loop picks per turn. + agents, defaultAgent := a.personaAgentPool(runtimeStack, a.dispatcher, guards, ag) // Spans fan out to the JSONL stderr recorder and — once wireFrontend has built // the gateway — to its observability buffer (cmd/cyrene/observability.go). @@ -410,11 +413,15 @@ func (a *app) wireRuntime() error { // emotion machine and affection move, and the returned mood label is written // onto Result.Mood and the assistant message (design/16 §3.3). a.emotion = a.buildEmotionObserver() - // Reasoning knobs from the default persona (budget / effort / thinking broadcast). + // Reasoning knobs from the default persona (budget / effort / thinking broadcast), + // which are also the per-turn defaults: the loop re-resolves them for each turn's + // role through PersonaKnobs, so a session bound to another role uses that role's + // budget instead. thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions() a.loop = loop.New(loop.Options{ - Agent: ag, + Agent: defaultAgent, + Agents: agents, Tools: a.dispatcher, Capacity: capDecider, OnCapacity: a.onCapacity, @@ -422,12 +429,13 @@ func (a *app) wireRuntime() error { OnTurnEnd: a.onTurnEnd, Memory: store, Retriever: a.retriever, - Extractor: a.buildMemoryExtractor(personaID), + Extractor: a.buildMemoryExtractor(), Checkpoint: checkpoint, Tracer: tracer, Flow: flowPub, Guards: guards, PersonaID: personaID, + PersonaKnobs: a.personaKnobs, Timezone: a.cfg.Timezone, MaxRounds: defaultMaxRounds, WorkingMemory: memory.NewWorkingMemory(0), diff --git a/internal/db/migrate_session_persona_test.go b/internal/db/migrate_session_persona_test.go new file mode 100644 index 0000000..0493328 --- /dev/null +++ b/internal/db/migrate_session_persona_test.go @@ -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) + } + } +} diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go index 5804a49..b1da10b 100644 --- a/internal/db/migrate_test.go +++ b/internal/db/migrate_test.go @@ -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") diff --git a/internal/db/migrations/0011_session_persona.down.sql b/internal/db/migrations/0011_session_persona.down.sql new file mode 100644 index 0000000..dbeb22d --- /dev/null +++ b/internal/db/migrations/0011_session_persona.down.sql @@ -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; diff --git a/internal/db/migrations/0011_session_persona.up.sql b/internal/db/migrations/0011_session_persona.up.sql new file mode 100644 index 0000000..602c06e --- /dev/null +++ b/internal/db/migrations/0011_session_persona.up.sql @@ -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 ''; diff --git a/internal/runtime/history/list_after.go b/internal/runtime/history/list_after.go index ab80a43..1af778e 100644 --- a/internal/runtime/history/list_after.go +++ b/internal/runtime/history/list_after.go @@ -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 != "" { diff --git a/internal/runtime/history/message_ops.go b/internal/runtime/history/message_ops.go index f9bb2df..d521f28 100644 --- a/internal/runtime/history/message_ops.go +++ b/internal/runtime/history/message_ops.go @@ -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 { diff --git a/internal/runtime/history/session_persona.go b/internal/runtime/history/session_persona.go new file mode 100644 index 0000000..34c5c23 --- /dev/null +++ b/internal/runtime/history/session_persona.go @@ -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 +} diff --git a/internal/runtime/history/session_persona_test.go b/internal/runtime/history/session_persona_test.go new file mode 100644 index 0000000..171b876 --- /dev/null +++ b/internal/runtime/history/session_persona_test.go @@ -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) + } +} diff --git a/internal/runtime/history/session_preview.go b/internal/runtime/history/session_preview.go index 2637e55..690071e 100644 --- a/internal/runtime/history/session_preview.go +++ b/internal/runtime/history/session_preview.go @@ -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 diff --git a/internal/runtime/history/session_read_test.go b/internal/runtime/history/session_read_test.go index 66668f7..a1146e8 100644 --- a/internal/runtime/history/session_read_test.go +++ b/internal/runtime/history/session_read_test.go @@ -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") { diff --git a/internal/runtime/history/store.go b/internal/runtime/history/store.go index 4290721..29d614b 100644 --- a/internal/runtime/history/store.go +++ b/internal/runtime/history/store.go @@ -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 )` diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index 24cd313..1f8a720 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -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", diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 2a7ca3a..b5b8460 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -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) diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index 4b28b45..c615e25 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -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". diff --git a/internal/runtime/loop/persona.go b/internal/runtime/loop/persona.go new file mode 100644 index 0000000..6918266 --- /dev/null +++ b/internal/runtime/loop/persona.go @@ -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 +} diff --git a/internal/runtime/loop/persona_test.go b/internal/runtime/loop/persona_test.go new file mode 100644 index 0000000..38eeb12 --- /dev/null +++ b/internal/runtime/loop/persona_test.go @@ -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 +} diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index 4d7082c..1316690 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -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 diff --git a/internal/runtime/loop/run.go b/internal/runtime/loop/run.go index 2ba6ea0..0123961 100644 --- a/internal/runtime/loop/run.go +++ b/internal/runtime/loop/run.go @@ -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) } diff --git a/internal/runtime/loop/sessionenv.go b/internal/runtime/loop/sessionenv.go index a8bfe94..8cf86cd 100644 --- a/internal/runtime/loop/sessionenv.go +++ b/internal/runtime/loop/sessionenv.go @@ -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} } diff --git a/internal/runtime/loop/spanlog.go b/internal/runtime/loop/spanlog.go index 314795d..6ec90ef 100644 --- a/internal/runtime/loop/spanlog.go +++ b/internal/runtime/loop/spanlog.go @@ -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 diff --git a/internal/runtime/loop/step.go b/internal/runtime/loop/step.go index 48100e1..58719ca 100644 --- a/internal/runtime/loop/step.go +++ b/internal/runtime/loop/step.go @@ -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) } diff --git a/internal/runtime/loop/stream.go b/internal/runtime/loop/stream.go index 40f5798..a1dce70 100644 --- a/internal/runtime/loop/stream.go +++ b/internal/runtime/loop/stream.go @@ -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. diff --git a/internal/runtime/loop/turn.go b/internal/runtime/loop/turn.go index ba4168e..0b6514f 100644 --- a/internal/runtime/loop/turn.go +++ b/internal/runtime/loop/turn.go @@ -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, diff --git a/internal/runtime/memory/extract.go b/internal/runtime/memory/extract.go index 4efeda0..a531e10 100644 --- a/internal/runtime/memory/extract.go +++ b/internal/runtime/memory/extract.go @@ -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} } diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index 8b75c6f..e482d71 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -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{}{} diff --git a/internal/services/gateway/admin_write_plugins_memory_sessions.go b/internal/services/gateway/admin_write_plugins_memory_sessions.go index db16a65..6b0498d 100644 --- a/internal/services/gateway/admin_write_plugins_memory_sessions.go +++ b/internal/services/gateway/admin_write_plugins_memory_sessions.go @@ -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 { diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index 73581f6..49e7927 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -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, } } diff --git a/internal/services/gateway/history_handler_test.go b/internal/services/gateway/history_handler_test.go index a281e48..2b65203 100644 --- a/internal/services/gateway/history_handler_test.go +++ b/internal/services/gateway/history_handler_test.go @@ -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) diff --git a/internal/services/gateway/history_persona_test.go b/internal/services/gateway/history_persona_test.go new file mode 100644 index 0000000..acb18a0 --- /dev/null +++ b/internal/services/gateway/history_persona_test.go @@ -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) + } +} diff --git a/internal/services/gateway/history_write.go b/internal/services/gateway/history_write.go index edc56a7..6c18c06 100644 --- a/internal/services/gateway/history_write.go +++ b/internal/services/gateway/history_write.go @@ -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")) } diff --git a/internal/services/gateway/persona_env.go b/internal/services/gateway/persona_env.go new file mode 100644 index 0000000..4862b31 --- /dev/null +++ b/internal/services/gateway/persona_env.go @@ -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) +} diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index 711ee99..423542d 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -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")) diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 977b761..bc320c2 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -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() diff --git a/internal/services/gateway/session_orchestrator.go b/internal/services/gateway/session_orchestrator.go index 833a27a..a062c21 100644 --- a/internal/services/gateway/session_orchestrator.go +++ b/internal/services/gateway/session_orchestrator.go @@ -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 -} diff --git a/internal/services/gateway/session_persist.go b/internal/services/gateway/session_persist.go index 4a0ecb2..578c168 100644 --- a/internal/services/gateway/session_persist.go +++ b/internal/services/gateway/session_persist.go @@ -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 diff --git a/internal/services/gateway/session_tail.go b/internal/services/gateway/session_tail.go new file mode 100644 index 0000000..15e6f02 --- /dev/null +++ b/internal/services/gateway/session_tail.go @@ -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 +} diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index a43abd3..a43316d 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -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") diff --git a/internal/services/gateway/ws_hub.go b/internal/services/gateway/ws_hub.go index a507d7d..e057a89 100644 --- a/internal/services/gateway/ws_hub.go +++ b/internal/services/gateway/ws_hub.go @@ -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, diff --git a/internal/services/gateway/ws_hub_turn.go b/internal/services/gateway/ws_hub_turn.go index 3c1392c..202f414 100644 --- a/internal/services/gateway/ws_hub_turn.go +++ b/internal/services/gateway/ws_hub_turn.go @@ -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