feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。 - ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线) - ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3) - ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId - Docs/api-client 版本 +1 - 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
This commit is contained in:
1 parent
3732c538cc
commit
5969bba3bd
22 files changed
+1396
-23
No files matched your search
+12
-3
@@ -110,7 +110,7 @@
|
||||
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
|
||||
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
|
||||
|
||||
> **契约版本 `v0.43`**(人格读侧补齐:**`GET /admin/personas` 每项新增可选 `thinking` 对象 + `GET /personas` 每项新增 `mood`**,均为新增字段,向后兼容。`thinking.showToUser` **保持三态**(`null` = 未配置),`mood` 为**角色级**当前情绪)。上一版 `v0.42`:多角色并存 + 按会话选角。
|
||||
> **契约版本 `v0.44`**(渠道级选角:**`PUT /admin/channels/{platform}/config` body 新增可选 `personaId`、`GET /admin/channels` 每账号新增可选 `personaId`**,均为新增字段,向后兼容;同时明确**角色解析优先级**:会话绑定 > 渠道绑定 > 全局默认,群聊/陌生人强制默认角色)。上一版 `v0.43`:人格读侧补齐(admin `thinking` 回读 + 用户侧角色级 `mood`)。
|
||||
|
||||
## 2. REST 端点(管理,`/api/v1/admin/*`)
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
|
||||
| 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) |
|
||||
| 5a | `/api/v1/admin/plugins/installed` | GET | ✅ | **已装运行时插件列表**(owner/admin 读):`{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]}`,`apiKey`/敏感 `env` 值**掩码**(如 `****1234`),`live` = 是否已连接注册,`toolCount` = 该 server 注册工具数 |
|
||||
| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 返回 `errcode=-14`、会话被 iLink 侧拒绝这一种) |
|
||||
| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取);适配器要求重新授权时附 **`needsRescan`** + **`lastError`**(v0.39,仅微信 iLink 返回 `errcode=-14`、会话被 iLink 侧拒绝这一种);**每账号附可选 `personaId`(v0.44)**=该账号的**渠道级角色绑定**(design/22 §2 方案 b;缺省 = 未绑定,非敏感、不掩码) |
|
||||
| 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 |
|
||||
| 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;POST 创建用户/签发 key 随 M10 落地) |
|
||||
| 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议与 `maxTokens` 预算);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启) |
|
||||
@@ -200,7 +200,8 @@
|
||||
- `POST /admin/channels/{platform}/message/test?accountId=` body `{targetId,content}` → `200 {data:{msg_id,msg_timestamp}}`。真实向 `targetId`(频道 id)发一条 kmarkdown 测试消息(KOOK `POST /message/create`),验证 bot 可达性。
|
||||
- `GET /admin/channels` 每条账号附 **`capabilities`**(可选 `[]string`,如 kook 的 `["friends","user_chats","channels","guilds","message_test"]`),供面板按平台渲染专属功能卡片。
|
||||
- **KOOK「token 即用、其余自动获取」**(适配器契约,非端点):填 KOOK 渠道时**只必填 `token`**(bot_token)。`base_url` 留空即用官方默认 `https://www.kookapp.cn/api/v3`;`receive_mode` 空默认 `ws`(WS 模式地址由 `GET /api/v3/gateway/index?compress=0` 自动获取,已接入);`webhook_path` 空默认 `/webhook/kook`。高级值(改用 webhook、`webhook_verify_token`、`webhook_encrypt_key`,仅 webhook 模式需要)经 PUT 的 `config` JSON 传入(前端走「额外 config」,如 `{"receive_mode":"webhook","webhook_verify_token":"..."}`)。**Connect 拿到 token 后自动调用 `GET /api/v3/user/me`**(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 `GET /admin/channels` 账号的 `botId`/`botUsername`/`botNickname` 显示;user/me 失败仅记日志、bot 身份为空,**不中断**连接/收发。token 只在 header / 写文件,**不打印、不进日志**。
|
||||
- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ `UpsertChannel` 该平台账号 → 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。
|
||||
- `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?,personaId?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ `UpsertChannel` 该平台账号 → 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。
|
||||
- **`personaId`(v0.44,渠道级绑定)**:=该账号**答话时的角色 id**(design/22 §2 方案 b,取值来自 `GET /personas`)。**未知角色 id 回 `400 bad_request`**(不绑一个答不了话的角色),**省略/`""` = 不绑**(解绑,写盘时该账号不带 `personaId` 键)。**写盘即生效**:保存后 `Reload(platform)` 会重建该平台 adapter,新角色在下一批入站消息上生效(无需重启)。**它不是身份/权限输入**——谁能做什么仍由 channel 层 actor 元数据 + 白名单决定(design/11 §0)。**优先级见 §4.2「角色解析优先级」**:渠道绑定**只**作用于该账号的**主人私聊**,且仅当主会话自身未绑角色;群聊/陌生人会话**强制默认角色**。
|
||||
- `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。
|
||||
- `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。
|
||||
- `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`。
|
||||
@@ -332,6 +333,13 @@
|
||||
### 4.2 会话与消息用法(聊天)
|
||||
- **列出会话**:`GET /api/v1/sessions` → `[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]`。**排序:置顶会话(`pinned=true`)最前,其次主会话 `id="web"`**(她与你的主线,最大化继承上下文),再按创建时间升序;次级 `web:<uuid>` 由 `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);群聊/陌生人会话仍**只读**、且强制默认角色。
|
||||
- **角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4)**:一个回合到底以哪个角色答话,**自上而下**取第一个命中的:
|
||||
1. **请求级覆盖** —— 仍**未实现**(design/22 §2 裁决规则 1,留待后续批次);
|
||||
2. **会话绑定** `chat_sessions.persona_id`(`POST/PATCH /sessions` 写入)—— 非空即赢;
|
||||
3. **渠道绑定** —— 该 IM 账号的 `personaId`(`PUT /admin/channels/{platform}/config`,v0.44)。**仅**作用于该账号的**主人私聊**,且仅当该会话(=镜像目标主会话 `web`)**自身未绑角色**时;
|
||||
4. **全局默认角色** `owner.yaml:defaultPersona` —— 兜底(`''` 始终 = 当前默认角色)。
|
||||
**群聊 / 陌生人隔离会话强制默认角色**(§7-4,更保守):这两类会话**跳过** 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是**只读**。**Web 主会话与 IM 主人私聊共享同一角色**(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
|
||||
- **模型上下文里的「谁说的」(v0.44,design/22 §7-5)**:一回合的上下文由该会话**最近 20 条**持久化消息(`chat_messages`,含 `persona_id`)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);**每条 assistant 历史消息按其说话角色标注**,模型收到形如 `[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。` 的独立 system 标注,以及一条声明当前角色的 `[会话角色] …` 段。**正文永不被改写**:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。**对客户端透明**(无新字段、无新端点)。
|
||||
- **改名 / 置顶 / 选角**:`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=<ms>&platform=&q=`。
|
||||
@@ -359,6 +367,7 @@
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.44`(**渠道级选角 + 「谁说的」进模型上下文**,对客户端**新增两个可选字段**,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):① **`PUT /api/v1/admin/channels/{platform}/config` body 新增可选 `personaId`**(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;**未知角色 `400 bad_request`**,**省略/`""` = 解绑**(写盘时该账号不带 `personaId` 键);沿用既有「读当前配置 → `UpsertChannel` → 落盘 `channels.json`(0600) → `bridge.Reload(platform)` 热更新」路径,保存即生效(下一批入站消息),**无新端点**。② **`GET /api/v1/admin/channels` 每账号新增可选 `personaId`**(非敏感,不掩码),供面板回填。③ **角色解析优先级明确化(对客户端语义澄清,无形状变更)**:**会话绑定 > 渠道绑定 > 全局默认角色**;渠道绑定**只**作用于该账号的**主人私聊**且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);**群聊/陌生人隔离会话强制默认角色**(§7-4,更保守,仍只读)。④ **模型上下文新增角色归属标注(§7-5 的剩下一半)**:一回合上下文改为该会话**最近 20 条**持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。` 独立 system 标注 + 一条 `[会话角色] …` 当前角色声明;**消息正文一字不改**(标注只走 system 消息,design/11 §10),**对客户端透明**(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ **向后兼容**:纯新增可选字段;未配置 `personaId` 的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。
|
||||
- `v0.43`(**人格读侧合并批:admin 回读 `thinking` + 用户侧角色级 `mood`**,对客户端**新增两个可选字段**,向后兼容;design/01 §2/§2.1、design/04 §3.2):① **`GET /api/v1/admin/personas` 每项新增可选 `thinking`**(`{budgetTokens,effort,showToUser,mode}`),与写侧同构、供面板回填编辑。**未配置的人格不带 `thinking` 键**(不是全零对象),与 `PUT` 的「省略 `thinking` = 保留已存」互为镜像。② **`showToUser` 三态原样回读**:**`null` = 未配置 → 保持现行为(thinking 帧照常推客户端)**,`true`/`false` = 显式开关;**服务端不把 `null` 拍平成 `false`**(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③ **`GET /api/v1/personas` 每项新增 `mood`(角色级)**:= 该角色**当前情绪状态**(取值同 `GET /meta/enums` 的 `moods`;`""` = 暂无被跟踪情绪,不伪造)。**情绪跟随角色、与会话无关**——同一角色的所有会话看到同一 `mood`;客户端登录/加载角色目录时用它**播种心情徽标**(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WS `mood` 帧为准。**用户侧不暴露 `thinking`**(模型调用旋钮属管理侧)。④ **`effort` 的能力提示归服务端**:读端点只回读**已存配置**,**不承诺**该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ **向后兼容**:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存 `thinking`)。
|
||||
- `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/<id>.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。
|
||||
|
||||
@@ -68,6 +68,25 @@ func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, erro
|
||||
return buildPlatformAdapters(platform, accounts[platform], f.log), nil
|
||||
}
|
||||
|
||||
// Persona returns the role bound to one channel account of a platform (design/22
|
||||
// §2 方案 b). It reads the same effective config Build does — channels.json wins,
|
||||
// env is the fallback — so the panel's save plus the bridge's hot reload stay the
|
||||
// single update path, and it is called once per adapter connect rather than per
|
||||
// message. An unknown platform/account, or an account without a personaId, returns
|
||||
// "" (no channel-level binding: the session binding or the global default applies).
|
||||
func (f *channelFactory) Persona(platform, accountID string) string {
|
||||
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
|
||||
if warn != nil {
|
||||
f.log.Warn("channels.json persona lookup fell back to env", "error", warn)
|
||||
}
|
||||
for _, acct := range accounts[platform] {
|
||||
if strings.TrimSpace(acct.ID) == strings.TrimSpace(accountID) {
|
||||
return strings.TrimSpace(acct.PersonaID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildPlatformAdapters builds every account of one platform into an adapter
|
||||
// instance. A failed account build is skipped with a warn (never fatal), matching
|
||||
// the "no credentials → warn and continue" contract of the bridge.
|
||||
|
||||
@@ -149,6 +149,24 @@ func (a *app) personaKnobs(personaID string) (loop.LoopKnobs, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
// personaName resolves a role id to its display name for the model context's
|
||||
// speaker attribution (loop.Options.PersonaName, design/22 §7-5). The loop names a
|
||||
// speaking role as "「昔涟」(xilian)" instead of a bare id, so the model can follow
|
||||
// who said what after a session switched roles. An empty or unknown id resolves to
|
||||
// "" and the loop falls back to the id alone — attribution never disappears just
|
||||
// because a name could not be resolved, and nothing here is an identity or
|
||||
// permission input.
|
||||
func (a *app) personaName(personaID string) string {
|
||||
if personaID == "" {
|
||||
return ""
|
||||
}
|
||||
p, ok := a.personas.Persona(personaID)
|
||||
if !ok || p == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(p.Name)
|
||||
}
|
||||
|
||||
// personaThinking maps the persona's `thinking` section onto the model-layer
|
||||
// knobs (design/01 §2.1 / design/04 §3.2). It returns (nil, "") when nothing
|
||||
// affects the request.
|
||||
|
||||
@@ -435,6 +435,7 @@ func (a *app) wireRuntime() error {
|
||||
Flow: flowPub,
|
||||
Guards: guards,
|
||||
PersonaID: personaID,
|
||||
PersonaName: a.personaName,
|
||||
PersonaKnobs: a.personaKnobs,
|
||||
Timezone: a.cfg.Timezone,
|
||||
MaxRounds: defaultMaxRounds,
|
||||
@@ -661,6 +662,10 @@ func (a *app) wireChannels() error {
|
||||
// Factory lets the panel save hot-replace a platform's adapters without a
|
||||
// restart (design/04 §3.1).
|
||||
Factory: factory,
|
||||
// ChannelPersona resolves an account's channel-level role from the same
|
||||
// config the factory builds from (design/22 §2 方案 b); the bridge caches it
|
||||
// per connected adapter and refreshes it on every reload.
|
||||
ChannelPersona: factory.Persona,
|
||||
})
|
||||
if berr != nil {
|
||||
return fmt.Errorf("channel bridge: %w", berr)
|
||||
|
||||
@@ -297,6 +297,14 @@ type ChannelAccount struct {
|
||||
// value disables the cap; a positive value pins it. When the pending set nears
|
||||
// this cap the review model prunes low-value messages.
|
||||
MaxMessages int `json:"maxMessages,omitempty"`
|
||||
// PersonaID is the role this account answers as (design/22 §2 方案 b: 渠道级绑定).
|
||||
// It is a persona id from the persona library, not a display name. It only
|
||||
// provides the role for the owner's private chat with this account and only when
|
||||
// the main session binds no role of its own: a session binding wins over it, the
|
||||
// global default (owner.yaml defaultPersona) is the final fallback, and a
|
||||
// group/stranger conversation is always forced onto the default role
|
||||
// (design/22 §7-4). Empty means "no channel-level binding".
|
||||
PersonaID string `json:"personaId,omitempty"`
|
||||
}
|
||||
|
||||
// Load reads configuration, merging an optional .env file (development only)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// This file covers the channel-level role binding of design/22 §2 方案 b: the
|
||||
// account's personaId is part of channels.json, so it must survive a save/load
|
||||
// round trip, be omitted when unset (an unbound account is not written as
|
||||
// "personaId": ""), and never be treated as a secret.
|
||||
|
||||
// TestChannelsPersonaIDRoundTrip proves the personaId is persisted verbatim and
|
||||
// read back on the account it belongs to.
|
||||
func TestChannelsPersonaIDRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
accts := map[string][]config.ChannelAccount{
|
||||
"telegram": {{ID: "a", PersonaID: "yuki", Config: map[string]any{"bot_token": "tk"}}},
|
||||
}
|
||||
if err := SaveChannels(path, accts); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"personaId": "yuki"`) {
|
||||
t.Fatalf("channels.json missing the bound role: %s", raw)
|
||||
}
|
||||
got, warn := LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("load warn = %v", warn)
|
||||
}
|
||||
if len(got["telegram"]) != 1 || got["telegram"][0].PersonaID != "yuki" {
|
||||
t.Fatalf("round trip personaId = %+v, want yuki", got["telegram"])
|
||||
}
|
||||
// A channel role is not a secret: masking the credentials must not hide it.
|
||||
if masked := Masked(got["telegram"][0]); masked.PersonaID != "yuki" {
|
||||
t.Fatalf("masked personaId = %q, want yuki", masked.PersonaID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsPersonaIDOmittedWhenUnbound locks the omission semantics: an account
|
||||
// with no channel binding writes no personaId field at all, so an existing account
|
||||
// is not silently bound to an empty-named role.
|
||||
func TestChannelsPersonaIDOmittedWhenUnbound(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := SaveChannels(path, map[string][]config.ChannelAccount{
|
||||
"telegram": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "personaId") {
|
||||
t.Fatalf("unbound account wrote a personaId: %s", raw)
|
||||
}
|
||||
got, _ := LoadChannels(path, nil)
|
||||
if len(got["telegram"]) != 1 || got["telegram"][0].PersonaID != "" {
|
||||
t.Fatalf("loaded personaId = %q, want empty", got["telegram"][0].PersonaID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpsertChannelPersonaID pins the save semantics the panel relies on: an Upsert
|
||||
// carries the whole account, so binding a role stores it, and a later save without
|
||||
// a personaId clears the binding instead of leaving a stale one behind.
|
||||
func TestUpsertChannelPersonaID(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounts := map[string][]config.ChannelAccount{
|
||||
"telegram": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
|
||||
}
|
||||
bound := UpsertChannel(accounts, "telegram", config.ChannelAccount{
|
||||
ID: "a", PersonaID: "yuki", Config: map[string]any{"bot_token": "tk"},
|
||||
})
|
||||
if bound["telegram"][0].PersonaID != "yuki" {
|
||||
t.Fatalf("upsert personaId = %q, want yuki", bound["telegram"][0].PersonaID)
|
||||
}
|
||||
|
||||
cleared := UpsertChannel(bound, "telegram", config.ChannelAccount{
|
||||
ID: "a", Config: map[string]any{"bot_token": "tk"},
|
||||
})
|
||||
if cleared["telegram"][0].PersonaID != "" {
|
||||
t.Fatalf("personaId after unbinding save = %q, want empty", cleared["telegram"][0].PersonaID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file owns the speaker attribution of the model context (DevDocs/design/22
|
||||
// §7-5). A process now hosts several roles at once and a session keeps its
|
||||
// conversation when its role is switched, so the model must be able to tell which
|
||||
// utterances in the context came from which role: the assistant messages of a
|
||||
// previous role must not read as "something I said", and the role answering now
|
||||
// must recognize itself.
|
||||
//
|
||||
// Two hard rules shape the rendering:
|
||||
// - The attributed metadata never enters a message body. A user message is
|
||||
// free text from the outside world, so letting it carry system-looking markup
|
||||
// would hand whoever writes it a chance to impersonate a system instruction
|
||||
// (design/11 §10). Attribution therefore travels as its own system messages,
|
||||
// never as a prefix inside user or assistant text.
|
||||
// - The rendering only ADDS system messages. It never reorders, rewrites or
|
||||
// drops a message, so the assembled prompt keeps its established shape: the
|
||||
// agent's instructions stay the first system message and the time / working
|
||||
// memory / memory blocks keep their existing order (assembleContext owns the
|
||||
// placement; this file only supplies the extra segment).
|
||||
//
|
||||
// The per-message speaker is read from the structured model.Message.SenderName
|
||||
// lane, because model/** is frozen in this batch and a Message has no dedicated
|
||||
// persona field. Only assistant messages are read: for an assistant turn the
|
||||
// sender IS the role that spoke it, while a labelled user message keeps its
|
||||
// pre-existing meaning (the third-party sender marker of design/16 §4.6), which
|
||||
// this file deliberately leaves alone.
|
||||
|
||||
// Attribution markers. Both are compile-time constants so no magic string
|
||||
// appears in the run path and a test can assert the exact wire shape.
|
||||
const (
|
||||
// roleContextMarker opens the leading segment that declares the speaking role.
|
||||
roleContextMarker = "[会话角色] "
|
||||
// speakerSwitchMarker opens a boundary note inserted where the speaker of the
|
||||
// conversation changes.
|
||||
speakerSwitchMarker = "[发言归属] "
|
||||
)
|
||||
|
||||
// speakerOf returns the role id labelled as the speaker of m, or "" when the
|
||||
// message carries no attribution. Only assistant messages are considered: a user
|
||||
// message's SenderName is the third-party display name lane of design/16 §4.6 and
|
||||
// has nothing to do with persona selection.
|
||||
func speakerOf(m model.Message) string {
|
||||
if m.Role != model.RoleAssistant {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.SenderName)
|
||||
}
|
||||
|
||||
// anySpeakerLabel reports whether any message of the conversation carries a
|
||||
// speaker label. When none does, attribution rendering is a complete no-op: a
|
||||
// single-role conversation (or a caller that never labels its messages) keeps the
|
||||
// exact pre-multi-character prompt it had before.
|
||||
func anySpeakerLabel(msgs []model.Message) bool {
|
||||
for _, m := range msgs {
|
||||
if speakerOf(m) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// attributeConversation renders the role context for one model step. It returns
|
||||
// the leading role declaration (ok = false when the conversation carries no
|
||||
// speaker label at all), and the conversation with a boundary note inserted
|
||||
// wherever the labelled speaker changes.
|
||||
//
|
||||
// The note announces the speaker for every labelled message that follows it until
|
||||
// the next note, which is exactly the granularity the model needs to answer "第 N
|
||||
// 条是谁说的" without touching a single message body. Rendering happens at request
|
||||
// build time from the live slice, so a window that was truncated or compacted in
|
||||
// the meantime can never end up with a message under the wrong speaker: a dropped
|
||||
// note is regenerated, and no note claims a position it does not have.
|
||||
func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message) (model.Message, bool, []model.Message) {
|
||||
if !anySpeakerLabel(msgs) {
|
||||
return model.Message{}, false, msgs
|
||||
}
|
||||
current := l.personaFor(ctx)
|
||||
out := make([]model.Message, 0, len(msgs)+2)
|
||||
prev := ""
|
||||
for _, m := range msgs {
|
||||
if s := speakerOf(m); s != "" && s != prev {
|
||||
out = append(out, model.TextMessage(model.RoleSystem, l.speakerSwitchText(s, current)))
|
||||
prev = s
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return l.roleContextMessage(current), true, out
|
||||
}
|
||||
|
||||
// roleContextMessage builds the leading segment that declares which role is
|
||||
// answering, so a role that was just switched in has a strong sense of its own
|
||||
// identity on top of its instructions (design/22 §7-5). It states the boundary
|
||||
// rule for the notes below: an attributed utterance belongs to the role it was
|
||||
// labelled with, not to the role answering now.
|
||||
func (l *Loop) roleContextMessage(current string) model.Message {
|
||||
var b strings.Builder
|
||||
b.WriteString(roleContextMarker)
|
||||
if current != "" {
|
||||
b.WriteString("你正以")
|
||||
b.WriteString(l.speakerLabel(current))
|
||||
b.WriteString("的身份对话。")
|
||||
}
|
||||
b.WriteString("本会话此前由不同角色相继发言:下面凡是标了「发言归属」的发言,都只属于标注的那个角色——" +
|
||||
"不要把别个角色说过的话当成你自己说过的,也不要继承他们的身份、口吻或立场;" +
|
||||
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
|
||||
return model.TextMessage(model.RoleSystem, b.String())
|
||||
}
|
||||
|
||||
// speakerSwitchText builds the boundary note for one speaking role. A stretch of
|
||||
// the answering role's own history is stated as "来自你自己" (so the model keeps
|
||||
// continuity with what it said before a switch), every other role is stated as
|
||||
// belonging to that role and explicitly not to the answering one.
|
||||
func (l *Loop) speakerSwitchText(personaID, current string) string {
|
||||
label := l.speakerLabel(personaID)
|
||||
if personaID != "" && personaID == current {
|
||||
return speakerSwitchMarker + "以下发言来自你自己——" + label + "。"
|
||||
}
|
||||
return speakerSwitchMarker + "以下发言来自" + label + ",不是你说的。"
|
||||
}
|
||||
|
||||
// speakerLabel renders a role reference for the model: the role's display name
|
||||
// with its id when a name resolver knows the role, the id alone otherwise. It
|
||||
// never returns an empty string, so a note always names someone.
|
||||
func (l *Loop) speakerLabel(personaID string) string {
|
||||
name := ""
|
||||
if l.personaName != nil {
|
||||
name = strings.TrimSpace(l.personaName(personaID))
|
||||
}
|
||||
if name == "" || name == personaID {
|
||||
return "「" + personaID + "」"
|
||||
}
|
||||
return "「" + name + "」(" + personaID + ")"
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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 covers design/22 §7-5 from the model's side: a conversation whose
|
||||
// assistant messages were spoken by different roles must reach the provider with
|
||||
// that attribution visible, without a single character being added to a message
|
||||
// body and without disturbing the assembled prompt's established order.
|
||||
|
||||
// speakerMsg builds one conversation message labelled with the role that spoke
|
||||
// it, exactly the way the gateway's transcript seeding labels an assistant turn
|
||||
// (model.Message.SenderName carries the speaking role id).
|
||||
func speakerMsg(role model.Role, personaID, text string) model.Message {
|
||||
m := model.TextMessage(role, text)
|
||||
m.SenderName = personaID
|
||||
return m
|
||||
}
|
||||
|
||||
// attributedLoop builds a two-role loop: instructions say which role answered, and
|
||||
// the name resolver mirrors the persona registry's id→display-name mapping.
|
||||
func attributedLoop(t *testing.T, ad model.Adapter) *Loop {
|
||||
t.Helper()
|
||||
xilian := personaAgent(t, "你是昔涟。", ad)
|
||||
yuki := personaAgent(t, "你是由纪。", ad)
|
||||
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
|
||||
return New(Options{
|
||||
Agent: xilian,
|
||||
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
|
||||
PersonaID: "xilian",
|
||||
PersonaName: func(id string) string { return names[id] },
|
||||
})
|
||||
}
|
||||
|
||||
// seedMemory writes one persona-scoped memory item for personaID so the round's
|
||||
// retrieval has something to inject.
|
||||
func seedMemory(t *testing.T, store *memory.Store, personaID, content string) {
|
||||
t.Helper()
|
||||
if _, err := store.Add(context.Background(), memory.MemoryItem{
|
||||
PersonaID: personaID, Content: content,
|
||||
Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed memory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// systemTexts returns the text of every system message of req, in order.
|
||||
func systemTexts(req model.Request) []string {
|
||||
out := make([]string, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == model.RoleSystem {
|
||||
out = append(out, messageText(m))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// indexOfSystemContaining returns the index of the first message whose text
|
||||
// contains want, or -1.
|
||||
func indexOfSystemContaining(req model.Request, want string) int {
|
||||
for i, m := range req.Messages {
|
||||
if strings.Contains(messageText(m), want) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// TestAttribution_SpeakersReachTheModelRequest is the acceptance case of
|
||||
// design/22 §7-5: the session now speaks as Y while the context still holds
|
||||
// utterances by X. The request the adapter receives must (a) open with Y's own
|
||||
// instructions, (b) declare which role is answering, (c) mark where the speaker
|
||||
// changed so "第 N 条是谁说的" is decidable, and (d) leave every message body
|
||||
// untouched — the attribution travels only in system messages.
|
||||
func TestAttribution_SpeakersReachTheModelRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "在呢"}}}}
|
||||
l := attributedLoop(t, ad)
|
||||
|
||||
msgs := []model.Message{
|
||||
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
|
||||
speakerMsg(model.RoleAssistant, "xilian", "还有一件事"),
|
||||
model.TextMessage(model.RoleUser, "继续"),
|
||||
speakerMsg(model.RoleAssistant, "yuki", "我在呢"),
|
||||
model.TextMessage(model.RoleUser, "你好"),
|
||||
}
|
||||
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-y", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := lastChatRequest(t, ad)
|
||||
|
||||
// (a) the role that answers now still owns the first system message.
|
||||
if got := messageText(req.Messages[0]); got != "你是由纪。" {
|
||||
t.Fatalf("first system = %q, want the bound role's instructions", got)
|
||||
}
|
||||
|
||||
sys := systemTexts(req)
|
||||
// instructions + the role-context segment + one boundary note per speaker
|
||||
// stretch (X's two assistant turns are one stretch).
|
||||
if len(sys) != 4 {
|
||||
t.Fatalf("system messages = %d (%q), want 4 (instructions + role context + two speaker boundaries)", len(sys), sys)
|
||||
}
|
||||
if sys[0] != "你是由纪。" {
|
||||
t.Fatalf("system[0] = %q, want the instructions untouched at the head", sys[0])
|
||||
}
|
||||
if !strings.HasPrefix(sys[1], roleContextMarker) {
|
||||
t.Fatalf("system[1] = %q, want the role context segment", sys[1])
|
||||
}
|
||||
for _, want := range []string{"「由纪」(yuki)", "不同角色相继发言"} {
|
||||
if !strings.Contains(sys[1], want) {
|
||||
t.Fatalf("role context %q missing %q", sys[1], want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sys[1], speakerSwitchMarker) {
|
||||
t.Fatalf("role context %q must not be mistaken for a boundary note", sys[1])
|
||||
}
|
||||
if !strings.HasPrefix(sys[2], speakerSwitchMarker) || !strings.Contains(sys[2], "「昔涟」(xilian)") || !strings.Contains(sys[2], "不是你说的") {
|
||||
t.Fatalf("system[2] = %q, want X's boundary note marking its utterances as not yours", sys[2])
|
||||
}
|
||||
if !strings.HasPrefix(sys[3], speakerSwitchMarker) || !strings.Contains(sys[3], "来自你自己") || !strings.Contains(sys[3], "「由纪」(yuki)") {
|
||||
t.Fatalf("system[3] = %q, want Y's own boundary note naming the answering role", sys[3])
|
||||
}
|
||||
|
||||
// (c) the boundary sits directly before the first utterance it covers, and the
|
||||
// second X utterance inherits the same speaker without a second note.
|
||||
firstX := indexOfSystemContaining(req, "我记住了")
|
||||
if firstX < 0 || req.Messages[firstX-1].Role != model.RoleSystem {
|
||||
t.Fatalf("X's first utterance at %d is not preceded by a boundary note (%+v)", firstX, req.Messages)
|
||||
}
|
||||
if idx := indexOfSystemContaining(req, "「昔涟」(xilian)"); idx > firstX {
|
||||
t.Fatalf("X boundary at %d must precede its utterance at %d", idx, firstX)
|
||||
}
|
||||
// The answering role's own history is re-opened by its own boundary note.
|
||||
self := indexOfSystemContaining(req, "我在呢")
|
||||
if self < 0 || !strings.Contains(messageText(req.Messages[self-1]), "来自你自己") {
|
||||
t.Fatalf("Y's utterance is not preceded by its self boundary: %+v", req.Messages)
|
||||
}
|
||||
|
||||
// (d) no body was rewritten: user text stays verbatim and carries no metadata,
|
||||
// and the assistant's own words are unchanged.
|
||||
var users []string
|
||||
for _, m := range req.Messages {
|
||||
if m.Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
users = append(users, messageText(m))
|
||||
}
|
||||
if len(users) != 2 || users[0] != "继续" || users[1] != "你好" {
|
||||
t.Fatalf("user bodies = %q, want the verbatim [继续 你好] (no system metadata in the user lane)", users)
|
||||
}
|
||||
for _, u := range users {
|
||||
if strings.Contains(u, speakerSwitchMarker) || strings.Contains(u, roleContextMarker) {
|
||||
t.Fatalf("user body %q carries system attribution metadata", u)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{"我记住了", "还有一件事", "我在呢"} {
|
||||
if idx := indexOfSystemContaining(req, body); idx >= 0 && strings.Contains(messageText(req.Messages[idx]), "「") {
|
||||
t.Fatalf("assistant body %q was rewritten with attribution", messageText(req.Messages[idx]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleContext_AttributionKeepsBlockOrder pins the placement rule: the role
|
||||
// declaration is added AFTER the established leading blocks (time, working memory,
|
||||
// memory) and immediately before the conversation, so the memory/time block order
|
||||
// and the instructions-first rule are untouched.
|
||||
func TestAssembleContext_AttributionKeepsBlockOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestMemoryStore(t)
|
||||
seedMemory(t, store, "yuki", "她喜欢冰美式")
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "嗯"}}}}
|
||||
xilian := personaAgent(t, "你是昔涟。", ad)
|
||||
yuki := personaAgent(t, "你是由纪。", ad)
|
||||
l := New(Options{
|
||||
Agent: xilian,
|
||||
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
|
||||
PersonaID: "xilian",
|
||||
PersonaName: func(id string) string { return map[string]string{"xilian": "昔涟", "yuki": "由纪"}[id] },
|
||||
Retriever: memory.NewRetriever(store),
|
||||
Timezone: "Asia/Shanghai",
|
||||
})
|
||||
|
||||
msgs := []model.Message{
|
||||
speakerMsg(model.RoleAssistant, "xilian", "我记住了"),
|
||||
model.TextMessage(model.RoleUser, "继续"),
|
||||
}
|
||||
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-order", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := lastChatRequest(t, ad)
|
||||
|
||||
timeIdx := indexOfSystemContaining(req, "当前时间")
|
||||
memIdx := indexOfSystemContaining(req, "冰美式")
|
||||
declIdx := indexOfSystemContaining(req, roleContextMarker)
|
||||
noteIdx := indexOfSystemContaining(req, speakerSwitchMarker)
|
||||
lastUser := -1
|
||||
for i, m := range req.Messages {
|
||||
if m.Role == model.RoleUser {
|
||||
lastUser = i
|
||||
}
|
||||
}
|
||||
if timeIdx < 0 || memIdx < 0 || declIdx < 0 || noteIdx < 0 || lastUser < 0 {
|
||||
t.Fatalf("missing block: time=%d mem=%d decl=%d note=%d user=%d (%+v)", timeIdx, memIdx, declIdx, noteIdx, lastUser, req.Messages)
|
||||
}
|
||||
if !(memIdx < declIdx && declIdx < noteIdx && noteIdx < lastUser) {
|
||||
t.Fatalf("block order = time:%d mem:%d decl:%d note:%d user:%d, want instructions < time < memory < declaration < notes < user", timeIdx, memIdx, declIdx, noteIdx, lastUser)
|
||||
}
|
||||
if got := messageText(req.Messages[0]); got != "你是由纪。" {
|
||||
t.Fatalf("first system = %q, want the instructions untouched at the head", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttribution_UnlabeledConversationIsUntouched locks the backward-compatible
|
||||
// half: a caller that labels nothing (every single-role path, IM, backthink, the
|
||||
// autonomous passes) gets exactly the prompt it had before, with no role segment
|
||||
// and no boundary notes.
|
||||
func TestAttribution_UnlabeledConversationIsUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "done"}}}}
|
||||
l := attributedLoop(t, ad)
|
||||
|
||||
msgs := []model.Message{
|
||||
model.TextMessage(model.RoleAssistant, "我记住了"),
|
||||
model.TextMessage(model.RoleUser, "继续"),
|
||||
}
|
||||
if _, err := l.Run(WithSessionEnv(context.Background(), roleEnv("yuki")), "sess-plain", msgs); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
req := lastChatRequest(t, ad)
|
||||
if len(req.Messages) != len(msgs)+1 {
|
||||
t.Fatalf("messages = %d, want %d (instructions + conversation, nothing added)", len(req.Messages), len(msgs)+1)
|
||||
}
|
||||
for _, s := range systemTexts(req) {
|
||||
if strings.Contains(s, roleContextMarker) || strings.Contains(s, speakerSwitchMarker) {
|
||||
t.Fatalf("unlabeled conversation got attribution %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ type Loop struct {
|
||||
flow *FlowPublisher
|
||||
guards *guardrail.Pipeline
|
||||
personaID string
|
||||
personaName func(personaID string) string
|
||||
personaKnobs func(personaID string) (LoopKnobs, bool)
|
||||
timeLocation *time.Location
|
||||
timeZoneName string
|
||||
@@ -157,6 +158,7 @@ func New(opts Options) *Loop {
|
||||
flow: opts.Flow,
|
||||
guards: opts.Guards,
|
||||
personaID: opts.PersonaID,
|
||||
personaName: opts.PersonaName,
|
||||
personaKnobs: opts.PersonaKnobs,
|
||||
timeLocation: timeLocation,
|
||||
timeZoneName: timeZoneName,
|
||||
|
||||
@@ -36,16 +36,7 @@ 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 {
|
||||
personaID := l.personaFor(ctx)
|
||||
if l.retriever == nil || personaID == "" {
|
||||
return messages
|
||||
}
|
||||
env := sessionEnvFrom(ctx)
|
||||
block, err := l.retrieveMemoryBlock(ctx, env, personaID, queryFromMessages(messages))
|
||||
if err != nil {
|
||||
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
|
||||
return messages
|
||||
}
|
||||
block := l.memoryBlock(ctx, messages)
|
||||
if block == "" {
|
||||
return messages
|
||||
}
|
||||
@@ -55,6 +46,25 @@ func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []mod
|
||||
return out
|
||||
}
|
||||
|
||||
// memoryBlock renders the round's memory block, or "" when there is nothing to
|
||||
// inject (no retriever/persona, a retrieval error, or no relevant memory). It is
|
||||
// split out from injectMemory because the assembly places other leading blocks
|
||||
// between the memory block and the conversation (the role context of design/22
|
||||
// §7-5), so it needs the block itself rather than a pre-prefixed slice.
|
||||
func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string {
|
||||
personaID := l.personaFor(ctx)
|
||||
if l.retriever == nil || personaID == "" {
|
||||
return ""
|
||||
}
|
||||
env := sessionEnvFrom(ctx)
|
||||
block, err := l.retrieveMemoryBlock(ctx, env, personaID, queryFromMessages(messages))
|
||||
if err != nil {
|
||||
l.log.Warn("memory inject", "error", err, "env", env.ActorEnv)
|
||||
return ""
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -105,9 +115,17 @@ func formatMemoryBlock(results []memory.Result, marker string) string {
|
||||
// re-injects the persona's relevant long-term memory — retrieval is re-run every
|
||||
// round from the current conversation, not cached from the first turn, so it
|
||||
// supports cross-round refresh — folds in the per-session working-memory summary
|
||||
// when present, and applies the optional TransformContext hook last so a caller
|
||||
// can project/filter what the model actually sees. It returns the input unchanged
|
||||
// when present, renders the role/speaker attribution of the conversation (design/22
|
||||
// §7-5), and applies the optional TransformContext hook last so a caller can
|
||||
// project/filter what the model actually sees. It returns the input unchanged
|
||||
// when nothing is configured.
|
||||
//
|
||||
// The existing order is preserved exactly: the leading blocks are the per-round
|
||||
// time, the working-memory summary and the memory block, in that order; the role
|
||||
// declaration sits right after them (immediately before the conversation it
|
||||
// describes) and the speaker boundary notes are inserted into the conversation
|
||||
// itself. A conversation with no speaker label renders no attribution at all, so
|
||||
// every single-role path keeps the prompt it had before multi-character support.
|
||||
func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, round int) []model.Message {
|
||||
var leading []model.Message
|
||||
// Per-round "current time" so the model naturally knows the wall clock (not a
|
||||
@@ -122,11 +140,16 @@ func (l *Loop) assembleContext(ctx context.Context, messages []model.Message, ro
|
||||
leading = append(leading, model.TextMessage(model.RoleSystem, "工作记忆: "+s))
|
||||
}
|
||||
}
|
||||
leading = append(leading, l.injectMemory(ctx, messages)...)
|
||||
|
||||
out := leading
|
||||
if block := l.memoryBlock(ctx, messages); block != "" {
|
||||
leading = append(leading, model.TextMessage(model.RoleSystem, block))
|
||||
}
|
||||
decl, ok, view := l.attributeConversation(ctx, messages)
|
||||
if ok {
|
||||
leading = append(leading, decl)
|
||||
}
|
||||
out := append(leading, view...)
|
||||
if l.transformContext != nil {
|
||||
out = l.transformContext(ctx, leading, round)
|
||||
out = l.transformContext(ctx, out, round)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -92,6 +92,13 @@ type Options struct {
|
||||
// scopes memory retrieval and extraction, the system prompt fallback and the
|
||||
// emotion hook. Blank skips memory injection/extraction.
|
||||
PersonaID string
|
||||
// PersonaName, when non-nil, resolves a role id to its display name for the
|
||||
// context's speaker attribution (design/22 §7-5): the role declaration and the
|
||||
// boundary notes name a speaking role as "「昔涟」(xilian)" instead of a bare id,
|
||||
// so the model can follow "who said this" without a lookup table of its own. An
|
||||
// unknown role, an empty name or a nil resolver all fall back to the id — the
|
||||
// attribution never disappears because a name could not be resolved.
|
||||
PersonaName func(personaID string) 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
|
||||
|
||||
@@ -61,6 +61,15 @@ func WithSessionEnv(ctx context.Context, env SessionEnv) context.Context {
|
||||
return context.WithValue(ctx, sessionEnvKey{}, env)
|
||||
}
|
||||
|
||||
// SessionEnvFrom returns the environment attached to ctx by WithSessionEnv, or the
|
||||
// owner-full default when none was attached (backward compatible). It is the read
|
||||
// side of WithSessionEnv, for a caller that receives only a context — a Chatter
|
||||
// implementation that must know the turn's role/isolation boundary, or a test
|
||||
// asserting what an inbound path resolved. The value is display/scope metadata
|
||||
// only: identity and permission were already decided by the actor metadata before
|
||||
// the env existed (project red line).
|
||||
func SessionEnvFrom(ctx context.Context) SessionEnv { return sessionEnvFrom(ctx) }
|
||||
|
||||
// sessionEnvFrom returns the env attached to ctx, or the owner-full default when
|
||||
// none was attached (backward compatible).
|
||||
func sessionEnvFrom(ctx context.Context) SessionEnv {
|
||||
|
||||
@@ -140,6 +140,14 @@ type Options struct {
|
||||
// (channels.json → env fallback). When non-nil the bridge supports hot reload;
|
||||
// nil keeps the bridge boot-only (Reload returns ErrNilFactory).
|
||||
Factory PlatformFactory
|
||||
// ChannelPersona, when non-nil, resolves the role bound to one channel account
|
||||
// (channels.json ChannelAccount.PersonaID, design/22 §2 方案 b): it is called with
|
||||
// the platform and the local account id, and returns "" when the account binds no
|
||||
// role. It is consulted once per adapter connect/reload (never per message) and
|
||||
// only for the owner's private chat, so a group/stranger conversation can never be
|
||||
// moved into a role's scope (design/22 §7-4). Nil keeps every channel on the global
|
||||
// default role.
|
||||
ChannelPersona func(platform, accountID string) string
|
||||
}
|
||||
|
||||
// Bridge owns the channel adapter lifecycle and the inbound message pipeline. It
|
||||
@@ -181,6 +189,12 @@ type Bridge struct {
|
||||
started bool
|
||||
closed bool
|
||||
factory PlatformFactory
|
||||
// channelPersona resolves an account's channel-level role from the effective
|
||||
// config (design/22 §2 方案 b); adapterPersona caches the resolved role per
|
||||
// connected adapter so the inbound path never re-reads channels.json. Both are
|
||||
// guarded by mu.
|
||||
channelPersona func(platform, accountID string) string
|
||||
adapterPersona map[string]string
|
||||
// proactiveCursor is the rotation cursor for the proactive-push no-signal
|
||||
// fallback (channelbridge/proactive.go), so the default channel is not always
|
||||
// the first whitelisted owner channel. Guarded by mu.
|
||||
@@ -251,6 +265,8 @@ func New(opts Options) (*Bridge, error) {
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
factory: opts.Factory,
|
||||
channelPersona: opts.ChannelPersona,
|
||||
adapterPersona: make(map[string]string),
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
|
||||
@@ -232,7 +232,13 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
// carried so the agent can see who is speaking (design/16 §4.6 §1-2). The
|
||||
// resolved actor decides who is the owner vs a third party structurally — never
|
||||
// from prompt text.
|
||||
//
|
||||
// The role this turn speaks as follows design/22 §2/§7-3/§7-4: the main session's
|
||||
// binding when the owner's private chat is mirrored into it, otherwise the
|
||||
// account's channel-level binding, otherwise the global default; a group or
|
||||
// stranger conversation is forced onto the default role.
|
||||
runEnv := loop.SessionEnv{ActorEnv: sess.Env(), MemWritable: sess.MemWritable}
|
||||
runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a))
|
||||
senderName := senderDisplayName(msg, actor)
|
||||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// This file owns which role an inbound IM turn speaks as (DevDocs/design/22 §2
|
||||
// 裁决规则 + §7-3 + §7-4). Identity and permission stay structural — the actor
|
||||
// metadata and the session's isolation boundary decide who may do what — while the
|
||||
// role only scopes the prompt, the memory namespace, the emotion machine and the
|
||||
// thinking budget.
|
||||
//
|
||||
// Precedence, top down (design/22 §2):
|
||||
// 1. the session-level binding — for an owner private chat that is the main web
|
||||
// session the message is mirrored into, so the IM mirror answers with the same
|
||||
// role the Web client is talking to (design/22 §7-3);
|
||||
// 2. the channel-level binding — the account's channels.json personaId, used when
|
||||
// the main session binds no role of its own (design/22 §2 方案 b);
|
||||
// 3. the global default — expressed as "", which the loop resolves to its
|
||||
// configured default role.
|
||||
//
|
||||
// A restricted conversation (group chat or stranger, i.e. every isolated sess-xxx
|
||||
// session) is deliberately excluded from 1 and 2: it is forced onto the default
|
||||
// role (design/22 §7-4, the conservative choice), so a channel binding can never
|
||||
// put a third party's conversation into a role's memory namespace.
|
||||
|
||||
// sessionPersonaReader is the optional narrower view of the history store used to
|
||||
// read a session's bound role. *history.Store implements it; a bridge built
|
||||
// without one (or with a store that predates design/22) simply resolves no session
|
||||
// binding, which is the global default.
|
||||
type sessionPersonaReader interface {
|
||||
SessionPersona(ctx context.Context, sessionID string) (string, error)
|
||||
}
|
||||
|
||||
// resolvePersona returns the role id one inbound turn runs as. target says where
|
||||
// the turn's conversation lives (the mirrored main session vs an isolated
|
||||
// session), sess carries the structurally resolved isolation boundary and account
|
||||
// is the receiving adapter's registration name (platform:confId) used for the
|
||||
// channel-level lookup.
|
||||
func (b *Bridge) resolvePersona(ctx context.Context, sess session.Session, target inboundTarget, account string) string {
|
||||
// design/22 §7-4: a group/stranger conversation never plays a bound role.
|
||||
if !target.private {
|
||||
return ""
|
||||
}
|
||||
// design/22 §7-3: an owner private chat mirrors into the main session, so it
|
||||
// answers as whatever role that session is bound to.
|
||||
if pid := b.mainSessionPersona(ctx); pid != "" {
|
||||
return pid
|
||||
}
|
||||
// design/22 §2 方案 b: the account's channel-level role, used only when the main
|
||||
// session binds no role of its own.
|
||||
return b.personaOfAdapter(account)
|
||||
}
|
||||
|
||||
// mainSessionPersona returns the role bound to the main web session, or "" when
|
||||
// the session inherits the global default. A missing reader or a read error also
|
||||
// yields "": a chat must never be blocked by a metadata lookup, and the fallback is
|
||||
// the pre-existing default role.
|
||||
func (b *Bridge) mainSessionPersona(ctx context.Context) string {
|
||||
if b.history == nil {
|
||||
return ""
|
||||
}
|
||||
reader, ok := b.history.(sessionPersonaReader)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
pid, err := reader.SessionPersona(ctx, history.MainSessionID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(pid)
|
||||
}
|
||||
|
||||
// personaOfAdapter returns the cached channel-level role of one adapter
|
||||
// registration name, or "" when none is bound (or none was resolved at connect
|
||||
// time).
|
||||
func (b *Bridge) personaOfAdapter(account string) string {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.adapterPersona[account]
|
||||
}
|
||||
|
||||
// rememberAdapterPersona resolves and caches the channel-level role of an adapter
|
||||
// that just connected. Resolving at connect/reload time (rather than per message)
|
||||
// keeps the inbound hot path free of config reads, while a panel save still takes
|
||||
// effect immediately: it writes channels.json and calls Reload, which rebuilds and
|
||||
// reconnects the platform's adapters, refreshing this cache.
|
||||
func (b *Bridge) rememberAdapterPersona(account string) {
|
||||
if b.channelPersona == nil {
|
||||
return
|
||||
}
|
||||
platform, accountID, _ := strings.Cut(account, ":")
|
||||
pid := strings.TrimSpace(b.channelPersona(platform, accountID))
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if pid == "" {
|
||||
delete(b.adapterPersona, account)
|
||||
return
|
||||
}
|
||||
b.adapterPersona[account] = pid
|
||||
}
|
||||
|
||||
// forgetAdapterPersona drops an adapter's cached channel role when the account is
|
||||
// removed (or renamed) by a reload, so a removed account can never keep answering
|
||||
// as a role that is no longer configured.
|
||||
func (b *Bridge) forgetAdapterPersona(account string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.adapterPersona, account)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"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"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// This file covers design/22 §2/§7-3/§7-4 from the channel side: which role an
|
||||
// inbound IM turn answers as. The chain asserted is the structural session → the
|
||||
// resolved SessionEnv the loop is driven with → the role named in it.
|
||||
|
||||
// personaHistory is the bridge's history store with one bound session role, so the
|
||||
// owner-private mirror can be asserted to follow the main session's role.
|
||||
type personaHistory struct {
|
||||
*captureHistory
|
||||
personas map[string]string
|
||||
}
|
||||
|
||||
func (p *personaHistory) SessionPersona(_ context.Context, sessionID string) (string, error) {
|
||||
return p.personas[sessionID], nil
|
||||
}
|
||||
|
||||
// envChatter is a Chatter that records the SessionEnv the bridge resolved for the
|
||||
// turn, which is where the role travels (the loop reads it from ctx).
|
||||
type envChatter struct {
|
||||
env loop.SessionEnv
|
||||
got bool
|
||||
}
|
||||
|
||||
func (e *envChatter) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
|
||||
e.env = loop.SessionEnvFrom(ctx)
|
||||
e.got = true
|
||||
return &loop.Result{Text: "在呢"}, nil
|
||||
}
|
||||
|
||||
// personaBridge builds a started bridge whose single telegram account is bound to
|
||||
// channelPersona, over a history store that binds mainSessionPersona to the main
|
||||
// session ("" = unbound).
|
||||
func personaBridge(t *testing.T, mainSessionPersona, channelPersona string) (*Bridge, *envChatter) {
|
||||
t.Helper()
|
||||
chat := &envChatter{}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b, err := New(Options{
|
||||
Loop: chat,
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: mainSessionPersona}},
|
||||
ChannelPersona: func(platform, accountID string) string { return channelPersona },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
b.Add(ad)
|
||||
b.Start()
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
return b, chat
|
||||
}
|
||||
|
||||
// TestInboundOwnerPrivateFollowsMainSessionRole is design/22 §7-3: an owner
|
||||
// private IM chat is mirrored into the main web session, so it must answer as the
|
||||
// role that session is bound to — not as the global default and not as the
|
||||
// account's channel-level role.
|
||||
func TestInboundOwnerPrivateFollowsMainSessionRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, chat := personaBridge(t, "xilian", "yuki")
|
||||
|
||||
b.process(context.Background(), directMessage())
|
||||
|
||||
if !chat.got {
|
||||
t.Fatal("the loop was never driven for the owner private message")
|
||||
}
|
||||
if chat.env.PersonaID != "xilian" {
|
||||
t.Fatalf("resolved persona = %q, want the main session's binding (xilian)", chat.env.PersonaID)
|
||||
}
|
||||
// The session binding wins, so the channel binding is never reached here.
|
||||
if got := b.personaOfAdapter("telegram"); got != "yuki" {
|
||||
t.Fatalf("cached channel persona = %q, want yuki (resolved at connect)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundChannelPersonaPrecedence pins the three-state precedence of design/22
|
||||
// §2: the channel-level binding applies only when neither the session nor anything
|
||||
// stronger resolves a role.
|
||||
func TestInboundChannelPersonaPrecedence(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
mainSession string
|
||||
channel string
|
||||
want string
|
||||
}{
|
||||
{name: "session binding wins", mainSession: "xilian", channel: "yuki", want: "xilian"},
|
||||
{name: "channel binding when session unbound", mainSession: "", channel: "yuki", want: "yuki"},
|
||||
{name: "default when neither binds", mainSession: "", channel: "", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, chat := personaBridge(t, tc.mainSession, tc.channel)
|
||||
b.process(context.Background(), directMessage())
|
||||
if !chat.got {
|
||||
t.Fatal("the loop was never driven")
|
||||
}
|
||||
if chat.env.PersonaID != tc.want {
|
||||
t.Fatalf("resolved persona = %q, want %q", chat.env.PersonaID, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundGroupForcesDefaultRole is design/22 §7-4: a group conversation — even
|
||||
// the owner's own messages inside it — keeps the default role even when the account
|
||||
// binds one, so the isolated group session can never be moved into a role's scope.
|
||||
func TestInboundGroupForcesDefaultRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &envChatter{}
|
||||
b, err := New(Options{
|
||||
Loop: chat,
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: true, MemWritable: false}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
|
||||
ChannelPersona: func(platform, accountID string) string { return "yuki" },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b.Add(ad)
|
||||
b.Start()
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
|
||||
b.process(context.Background(), adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup, GroupID: "-20",
|
||||
SenderID: "100", Sender: adapter.Actor{Role: adapter.RoleOwner}, Content: "我在群里", ContentType: adapter.ContentText,
|
||||
})
|
||||
|
||||
if !chat.got {
|
||||
t.Fatal("the loop was never driven")
|
||||
}
|
||||
if chat.env.PersonaID != "" {
|
||||
t.Fatalf("group conversation persona = %q, want the default role (empty)", chat.env.PersonaID)
|
||||
}
|
||||
// The isolation boundary itself is untouched by role resolution.
|
||||
if chat.env.MemWritable {
|
||||
t.Fatalf("env = %+v, want the read-only restricted lane preserved", chat.env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePersonaStrangerUsesDefaultRole covers the other restricted lane
|
||||
// directly: a stranger's isolated private conversation is not the owner's mirror,
|
||||
// so both the session binding and the channel binding are skipped (design/22 §7-4).
|
||||
// The pipeline path is asserted for groups above; a stranger message only reaches
|
||||
// the loop when the participation gate admits it (design/12 §6), which is
|
||||
// orthogonal to role resolution and asserted elsewhere.
|
||||
func TestResolvePersonaStrangerUsesDefaultRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := New(Options{
|
||||
Loop: &envChatter{},
|
||||
Sessions: &stubSessions{},
|
||||
Owner: &user.OwnerProfile{},
|
||||
History: &personaHistory{captureHistory: &captureHistory{}, personas: map[string]string{history.MainSessionID: "xilian"}},
|
||||
ChannelPersona: func(platform, accountID string) string { return "yuki" },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
stranger := session.Session{SessionID: "sess-s", Owner: false, MemWritable: false}
|
||||
target := inboundTargetFor(stranger, "telegram")
|
||||
if target.private {
|
||||
t.Fatalf("stranger target = %+v, want an isolated (non-mirrored) session", target)
|
||||
}
|
||||
if got := b.resolvePersona(context.Background(), stranger, target, "telegram"); got != "" {
|
||||
t.Fatalf("stranger persona = %q, want the default role (empty)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelPersonaLookupSplitsAccountName proves the account-level lookup: a
|
||||
// multi-account platform resolves the role of the account that received the
|
||||
// message (platform:confId), not of the platform as a whole.
|
||||
func TestChannelPersonaLookupSplitsAccountName(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotPlatform, gotAccount string
|
||||
b, err := New(Options{
|
||||
Loop: &envChatter{},
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
ChannelPersona: func(platform, accountID string) string {
|
||||
gotPlatform, gotAccount = platform, accountID
|
||||
if accountID == "conf0" {
|
||||
return "yuki"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "telegram:conf0"}
|
||||
b.Add(ad)
|
||||
b.Start()
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
|
||||
if gotPlatform != "telegram" || gotAccount != "conf0" {
|
||||
t.Fatalf("lookup = %s/%s, want telegram/conf0", gotPlatform, gotAccount)
|
||||
}
|
||||
if got := b.personaOfAdapter("telegram:conf0"); got != "yuki" {
|
||||
t.Fatalf("cached persona = %q, want yuki", got)
|
||||
}
|
||||
if got := b.personaOfAdapter("telegram:conf1"); got != "" {
|
||||
t.Fatalf("unbound account persona = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovedAccountForgetsChannelPersona locks the reload contract: an account
|
||||
// dropped by a reload must not keep answering as its old channel role.
|
||||
func TestRemovedAccountForgetsChannelPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
chat := &envChatter{}
|
||||
b, err := New(Options{
|
||||
Loop: chat,
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
ChannelPersona: func(platform, accountID string) string { return "yuki" },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "telegram"}
|
||||
b.Add(ad)
|
||||
b.Start()
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
if got := b.personaOfAdapter("telegram"); got != "yuki" {
|
||||
t.Fatalf("cached persona = %q, want yuki", got)
|
||||
}
|
||||
|
||||
b.removeAdapter(ad, nil)
|
||||
|
||||
if got := b.personaOfAdapter("telegram"); got != "" {
|
||||
t.Fatalf("cached persona after removal = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
|
||||
b.readCancels[a.Name()] = rcancel
|
||||
b.connected[a.Name()] = true
|
||||
b.mu.Unlock()
|
||||
// Cache the account's channel-level role now that the adapter is live: the
|
||||
// inbound path reads the cache instead of the config, and a reload (which the
|
||||
// panel's save triggers) re-runs this to pick up a new binding.
|
||||
b.rememberAdapterPersona(a.Name())
|
||||
b.wg.Add(1)
|
||||
go b.readLoop(a, readCtx)
|
||||
b.log.Info("channel connected", "channel", a.Name())
|
||||
@@ -169,5 +173,6 @@ func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc)
|
||||
delete(b.connected, name)
|
||||
delete(b.readCancels, name)
|
||||
b.mu.Unlock()
|
||||
b.forgetAdapterPersona(name)
|
||||
b.log.Info("channel (reload) removed", "channel", name)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// This file covers the admin half of the channel-level role binding (design/22 §2
|
||||
// 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the
|
||||
// existing read-modify-write + bridge.Reload path, an unknown role is refused
|
||||
// instead of binding a role that cannot answer, and the list endpoint reports the
|
||||
// binding back.
|
||||
|
||||
// TestAdminSaveChannelConfigPersonaID saves a channel role, asserts it reached
|
||||
// channels.json and the list wire shape, then clears it.
|
||||
func TestAdminSaveChannelConfigPersonaID(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||||
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "yuki"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
accounts, warn := runtimeconfig.LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("load channels warn = %v", warn)
|
||||
}
|
||||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "yuki" {
|
||||
t.Fatalf("channels.json telegram = %+v, want personaId yuki", accounts["telegram"])
|
||||
}
|
||||
if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" {
|
||||
t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded)
|
||||
}
|
||||
|
||||
// The list endpoint reports the binding so the panel can render it.
|
||||
var listed []struct {
|
||||
Name string `json:"name"`
|
||||
Accounts []struct {
|
||||
ID string `json:"id"`
|
||||
PersonaID string `json:"personaId"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
_, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
||||
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &listed); err != nil {
|
||||
t.Fatalf("decode channels list: %v (body=%s)", err, gBody)
|
||||
}
|
||||
var accts []struct {
|
||||
ID string `json:"id"`
|
||||
PersonaID string `json:"personaId"`
|
||||
}
|
||||
for _, entry := range listed {
|
||||
if entry.Name == "telegram" {
|
||||
accts = entry.Accounts
|
||||
}
|
||||
}
|
||||
if len(accts) != 1 || accts[0].PersonaID != "yuki" {
|
||||
t.Fatalf("GET /admin/channels telegram accounts = %+v, want personaId yuki (body=%s)", accts, gBody)
|
||||
}
|
||||
|
||||
// Saving the same account without a personaId unbinds it.
|
||||
resp, body = doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||||
map[string]any{"id": "conf0", "token": "bot-tok"}, tok)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("unbind status = %d, want 201 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
accounts, _ = runtimeconfig.LoadChannels(path, nil)
|
||||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "" {
|
||||
t.Fatalf("after unbind = %+v, want personaId cleared", accounts["telegram"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSaveChannelConfigRejectsUnknownPersonaID keeps the save fail-closed: a
|
||||
// channel bound to a role the library does not know would silently answer as the
|
||||
// default role, so it is refused and nothing is written or reloaded.
|
||||
func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config",
|
||||
map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "nobody"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "personaId") {
|
||||
t.Fatalf("rejection body = %s, want it to name personaId", body)
|
||||
}
|
||||
if len(store.reloaded) != 0 {
|
||||
t.Fatalf("reloaded = %v, want none for a refused save", store.reloaded)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("a refused save must not write channels.json (stat err = %v)", err)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ type AdminChannelAccount struct {
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
||||
MsgPerMin int `json:"msgPerMin,omitempty"`
|
||||
MaxMessages int `json:"maxMessages,omitempty"`
|
||||
// PersonaID is the role this account answers as (design/22 §2 方案 b), omitted
|
||||
// when the account binds none. It is a persona id, not a secret.
|
||||
PersonaID string `json:"personaId,omitempty"`
|
||||
// BotID/BotUsername/BotNickname are the connected bot's resolved identity
|
||||
// (kook fetches it from /api/v3/user/me on connect). They come from the live
|
||||
// adapter, not the persisted channels.json config, and are non-sensitive (the
|
||||
@@ -72,6 +75,11 @@ type channelConfigRequest struct {
|
||||
GroupMsgPerMin int `json:"groupMsgPerMin"`
|
||||
MsgPerMin int `json:"msgPerMin"`
|
||||
MaxMessages int `json:"maxMessages"`
|
||||
// PersonaID is the role this account answers as (design/22 §2 方案 b). An empty
|
||||
// value leaves the account unbound (the session binding or the global default
|
||||
// applies); an unknown id is refused with 400 so a save can never bind a role
|
||||
// that cannot answer.
|
||||
PersonaID string `json:"personaId"`
|
||||
}
|
||||
|
||||
// adminSaveChannelConfig persists one account's config for a platform into
|
||||
@@ -95,6 +103,13 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
|
||||
return
|
||||
}
|
||||
// A channel-level role must exist in the persona library, or the account would
|
||||
// be bound to a role that cannot answer (design/22 §2 方案 b). An omitted/empty
|
||||
// personaId is always accepted: it means "no channel binding".
|
||||
if !s.requireKnownPersona(w, req.PersonaID) {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
return
|
||||
}
|
||||
cfg := channelConfigFromRequest(req)
|
||||
// weixin-ilink may be saved with an empty config: the panel creates the account
|
||||
// first, then the user scans the QR (get_bot_qrcode) which fills bot_token/bot_id/
|
||||
@@ -116,6 +131,7 @@ func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request)
|
||||
GroupMsgPerMin: req.GroupMsgPerMin,
|
||||
MsgPerMin: req.MsgPerMin,
|
||||
MaxMessages: req.MaxMessages,
|
||||
PersonaID: strings.TrimSpace(req.PersonaID),
|
||||
})
|
||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||
s.auditClaims(r, "channel.save", "error")
|
||||
@@ -253,6 +269,7 @@ func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
|
||||
GroupMsgPerMin: acct.GroupMsgPerMin,
|
||||
MsgPerMin: acct.MsgPerMin,
|
||||
MaxMessages: acct.MaxMessages,
|
||||
PersonaID: acct.PersonaID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"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 end-to-end half of design/22 §7-5: a session that switched
|
||||
// roles must keep the conversation it had, and the model must be able to tell
|
||||
// which utterances came from the earlier role. The chain asserted here is:
|
||||
// chat_messages (per-message persona_id) → the orchestrator's seeded turn context
|
||||
// → the loop's speaker attribution → the model.Request the adapter receives.
|
||||
|
||||
// requestTexts returns the text of every message of req, in order.
|
||||
func requestTexts(req model.Request) []string {
|
||||
out := make([]string, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
out = append(out, messageTextOf(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// messageTextOf concatenates the text parts of m.
|
||||
func messageTextOf(m model.Message) string {
|
||||
var b strings.Builder
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText {
|
||||
b.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestChatTurnCarriesSpeakerAttributionFromTranscript is design/22 §7-5 accepted
|
||||
// from the process boundary: the session already holds turns spoken by role X, it
|
||||
// is now bound to role Y, and one more chat turn arrives. The request must carry
|
||||
// X's earlier utterance with its speaker attributed to X (not to Y), keep every
|
||||
// stored body verbatim, and still open with Y's own instructions.
|
||||
func TestChatTurnCarriesSpeakerAttributionFromTranscript(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)
|
||||
}
|
||||
// The conversation as it stood while the session spoke as X, with per-message
|
||||
// persona ids exactly as the orchestrator persists them (design/22 §7-5).
|
||||
for _, m := range []history.Message{
|
||||
{SessionID: sub, Role: history.RoleUser, Content: "你还记得吗", PersonaID: "xilian", CreatedAt: 1000},
|
||||
{SessionID: sub, Role: history.RoleAssistant, Content: "我记住了,你喜欢冰美式", PersonaID: "xilian", CreatedAt: 1001},
|
||||
} {
|
||||
if err := st.Append(ctx, m); err != nil {
|
||||
t.Fatalf("seed transcript: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ad := &personaAdapter{}
|
||||
xilian := gatewayAgentFor(t, "你是昔涟。", ad)
|
||||
yuki := gatewayAgentFor(t, "你是由纪。", ad)
|
||||
names := map[string]string{"xilian": "昔涟", "yuki": "由纪"}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: xilian,
|
||||
Agents: map[string]*agent.Agent{"xilian": xilian, "yuki": yuki},
|
||||
PersonaID: "xilian",
|
||||
PersonaName: func(id string) string { return names[id] },
|
||||
})
|
||||
_, 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")
|
||||
}
|
||||
req := ad.reqs[len(ad.reqs)-1]
|
||||
|
||||
// The answering role still owns the head of the prompt.
|
||||
if got := firstSystemText(req); got != "你是由纪。" {
|
||||
t.Fatalf("first system = %q, want role Y's prompt", got)
|
||||
}
|
||||
|
||||
// X's earlier utterance reached the model, verbatim (the turn is not amnesiac
|
||||
// across the role switch).
|
||||
texts := requestTexts(req)
|
||||
xIdx := -1
|
||||
for i, txt := range texts {
|
||||
if txt == "我记住了,你喜欢冰美式" {
|
||||
xIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if xIdx < 0 {
|
||||
t.Fatalf("X's earlier utterance missing from the request: %q", texts)
|
||||
}
|
||||
if xIdx == 0 || req.Messages[xIdx].Role == model.RoleSystem {
|
||||
t.Fatalf("X's utterance must stay a plain assistant message, got %+v", req.Messages[xIdx])
|
||||
}
|
||||
if req.Messages[xIdx].SenderName != "xilian" {
|
||||
t.Fatalf("speaker label = %q, want xilian (the role that actually spoke it)", req.Messages[xIdx].SenderName)
|
||||
}
|
||||
|
||||
// The speaker boundary names X and marks the stretch as not Y's own words.
|
||||
note := ""
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == model.RoleSystem && strings.Contains(messageTextOf(m), "「昔涟」(xilian)") {
|
||||
note = messageTextOf(m)
|
||||
break
|
||||
}
|
||||
}
|
||||
if note == "" || !strings.Contains(note, "不是你说的") {
|
||||
t.Fatalf("no speaker boundary for X found: %q", texts)
|
||||
}
|
||||
|
||||
// No stored body was rewritten, and the newly sent user text carries no system
|
||||
// metadata (the user lane stays free text, design/11 §10).
|
||||
for _, m := range req.Messages {
|
||||
if m.Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
if txt := messageTextOf(m); txt != "继续" && txt != "你还记得吗" {
|
||||
t.Fatalf("user body = %q, want a stored/verbatim user text", txt)
|
||||
}
|
||||
}
|
||||
if xIdx+1 <= len(req.Messages) && strings.Contains(req.Messages[xIdx].Content[0].Text, "[") {
|
||||
t.Fatalf("attribution leaked into X's body: %q", req.Messages[xIdx].Content[0].Text)
|
||||
}
|
||||
}
|
||||
@@ -243,16 +243,27 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
st.gen++
|
||||
gen := st.gen
|
||||
st.mode = ModeThinking
|
||||
st.tail = o.boundedTail(messages)
|
||||
// A fresh turn continues the session's conversation: the persisted transcript
|
||||
// is seeded in front of the just-sent messages (design/15 §2.2/§4, design/22
|
||||
// §7-5), and its assistant utterances keep the role that spoke them. The read
|
||||
// happens here, before persistUserMessages below writes the new message, so the
|
||||
// message just sent is never duplicated in the context. A store without history
|
||||
// (or an empty session) leaves the turn on the caller's messages alone. It is a
|
||||
// bounded indexed read (transcriptContextCount rows) under the state lock, which
|
||||
// no running turn ever holds, so it cannot block a Submit behind a turn.
|
||||
turn := o.startTurnMessages(ctx, sessionID, messages)
|
||||
st.tail = o.boundedTail(turn)
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
st.cancel = cancel
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
|
||||
// Only the messages that arrived now are persisted: the seeded transcript is
|
||||
// already durable, and re-appending it would duplicate the whole conversation.
|
||||
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
|
||||
|
||||
if sync {
|
||||
res, err := o.run(runCtx, sessionID, messages, env)
|
||||
res, err := o.run(runCtx, sessionID, turn, env)
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
@@ -260,7 +271,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
|
||||
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
|
||||
}
|
||||
|
||||
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel, deviceID, msgMode)
|
||||
go o.goTurn(sessionID, gen, turn, env, runCtx, cancel, deviceID, msgMode)
|
||||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||||
}
|
||||
|
||||
@@ -297,7 +308,7 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
|
||||
return nil
|
||||
}
|
||||
if res != nil && res.Text != "" {
|
||||
st.tail = o.boundedTail(concatMessages(st.tail, model.TextMessage(model.RoleAssistant, res.Text)))
|
||||
st.tail = o.boundedTail(concatMessages(st.tail, assistantTailMessage(res.Text, personaID)))
|
||||
}
|
||||
if len(st.pending) > 0 {
|
||||
next := st.pending[0]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file owns the reconstruction of a turn's conversation context from the
|
||||
// persisted transcript (design/15 §2.2/§4 "上下文有界:只保留最近 N 条上下文", design/02
|
||||
// §1 主会话承载完整上下文, design/22 §7-5). A turn used to start from the newly sent
|
||||
// message alone, which left the model with no sight of the conversation it was
|
||||
// continuing; the assistant utterances of earlier turns also carried no record of
|
||||
// which role had spoken them, so a role switched into a session could read another
|
||||
// role's words as its own.
|
||||
//
|
||||
// The seeding fixes both halves: a fresh turn carries the session's most recent
|
||||
// persisted messages, and every assistant message is labelled with the role that
|
||||
// spoke it (model.Message.SenderName) so the loop's context assembly can attribute
|
||||
// it (loop/attribution.go). Only labels travel on the messages — no body is
|
||||
// rewritten.
|
||||
//
|
||||
// Scope note: the seeded window is the conversation, so the loop's per-step
|
||||
// background memory extraction now reads those recent messages instead of the
|
||||
// single newly sent one. Extracted items are still filed under the turn's role
|
||||
// (loop.persistMemories, design/22 §4.2) and re-extraction of the same content is
|
||||
// deduplicated by the extractor, but a fact first stated while another role was
|
||||
// active can now be picked up by the current role if it was never extracted at the
|
||||
// time. Narrowing the extraction input to the current turn's messages is a
|
||||
// separate decision and is left as a follow-up.
|
||||
|
||||
// transcriptContextCount bounds how many persisted messages a fresh turn carries
|
||||
// into the model context. It mirrors the orchestrator's continuation tail bound
|
||||
// (defaultMaxTail), so a seeded turn and a resumed turn stay the same size; the
|
||||
// loop's own context budget still governs compaction beyond that.
|
||||
const transcriptContextCount = 20
|
||||
|
||||
// transcriptReader is the narrow history view the seeding needs. *history.Store
|
||||
// satisfies it; a store that does not (a test fake, or a gateway built without
|
||||
// persistence) simply yields no seeded context, so the orchestrator keeps running
|
||||
// exactly as before.
|
||||
type transcriptReader interface {
|
||||
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
|
||||
}
|
||||
|
||||
// startTurnMessages returns the conversation a fresh turn runs on: the session's
|
||||
// seeded transcript followed by the messages the client just sent. When the
|
||||
// transcript is unavailable the caller's messages are returned untouched, which is
|
||||
// the pre-existing behavior.
|
||||
func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string, messages []model.Message) []model.Message {
|
||||
seed := o.sessionTranscript(ctx, sessionID)
|
||||
if len(seed) == 0 {
|
||||
return messages
|
||||
}
|
||||
return concatMessages(seed, messages...)
|
||||
}
|
||||
|
||||
// sessionTranscript reads the session's most recent persisted messages and maps
|
||||
// them to model messages (oldest first). A nil/unreadable store or a read error
|
||||
// yields nil so a chat is never blocked by a metadata lookup, and a session with
|
||||
// no history yields nothing — both degrade to the pre-existing "new message only"
|
||||
// context.
|
||||
func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string) []model.Message {
|
||||
if o.history == nil || sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
reader, ok := o.history.(transcriptReader)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
recent, err := reader.Recent(ctx, sessionID, transcriptContextCount)
|
||||
if err != nil {
|
||||
o.log.Debug("turn transcript read", "err", err, "session", sessionID)
|
||||
return nil
|
||||
}
|
||||
out := make([]model.Message, 0, len(recent))
|
||||
for _, h := range recent {
|
||||
out = append(out, transcriptMessage(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// transcriptMessage maps one persisted chat message onto the model message a turn
|
||||
// continues from. An assistant message carries the role that spoke it (the stored
|
||||
// persona_id of the turn) as its speaker label, so the context can attribute it to
|
||||
// that role even after the session was switched; a non-assistant message keeps its
|
||||
// stored sender label (the third-party display name of design/16 §4.6) and is never
|
||||
// relabelled as a speaking role.
|
||||
func transcriptMessage(h history.Message) model.Message {
|
||||
role := historyRoleToModel(h.Role)
|
||||
msg := model.Message{
|
||||
Role: role,
|
||||
Content: []model.Content{{Type: model.ContentTypeText, Text: h.Content}},
|
||||
}
|
||||
if h.Image != "" {
|
||||
msg.Content = append(msg.Content, model.Content{Type: model.ContentTypeImage, ImageURL: h.Image})
|
||||
}
|
||||
if role == model.RoleAssistant {
|
||||
msg.SenderName = h.PersonaID
|
||||
return msg
|
||||
}
|
||||
msg.SenderName = h.SenderName
|
||||
return msg
|
||||
}
|
||||
|
||||
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
|
||||
// defaulting an unknown role to the user lane.
|
||||
func historyRoleToModel(role string) model.Role {
|
||||
switch role {
|
||||
case history.RoleAssistant:
|
||||
return model.RoleAssistant
|
||||
case history.RoleSystem:
|
||||
return model.RoleSystem
|
||||
default:
|
||||
return model.RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
// assistantTailMessage builds the continuation-tail entry for one settled turn's
|
||||
// reply, labelled with the role that spoke it (design/22 §7-5): the tail is the
|
||||
// in-memory twin of the seeded transcript, so a role switched in later must be able
|
||||
// to tell an earlier role's reply apart from its own words on the very next queued
|
||||
// turn.
|
||||
func assistantTailMessage(text, personaID string) model.Message {
|
||||
msg := model.TextMessage(model.RoleAssistant, text)
|
||||
msg.SenderName = personaID
|
||||
return msg
|
||||
}
|
||||
Reference in new issue
Block a user