From 5969bba3bd81cb4e2424ac7f02d52e73e3e8a9b7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 21:01:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel,runtime):=20=E6=89=B9=E6=AC=A12=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=20=E2=80=94=20=E6=B6=88=E6=81=AF=E7=BA=A7?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=BD=92=E5=B1=9E=E8=BF=9B=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=20+=20IM=20=E7=A7=81=E8=81=8A=E9=9A=8F=E4=B8=BB?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=A7=92=E8=89=B2=20+=20=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=BA=A7=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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 交叉编译 --- Docs/api-client.md | 15 +- cmd/cyrene/channels.go | 19 ++ cmd/cyrene/persona_prompt.go | 18 ++ cmd/cyrene/wire.go | 5 + internal/config/config.go | 8 + .../runtimeconfig/channels_persona_test.go | 93 +++++++ internal/runtime/loop/attribution.go | 141 ++++++++++ internal/runtime/loop/attribution_test.go | 245 +++++++++++++++++ internal/runtime/loop/loop.go | 2 + internal/runtime/loop/memory.go | 55 ++-- internal/runtime/loop/options.go | 7 + internal/runtime/loop/sessionenv.go | 9 + .../services/channelbridge/channelbridge.go | 16 ++ internal/services/channelbridge/inbound.go | 6 + internal/services/channelbridge/persona.go | 114 ++++++++ .../services/channelbridge/persona_test.go | 249 ++++++++++++++++++ internal/services/channelbridge/reload.go | 5 + .../gateway/admin_channel_persona_test.go | 108 ++++++++ .../gateway/admin_write_channel_config.go | 17 ++ .../gateway/session_attribution_test.go | 139 ++++++++++ .../services/gateway/session_orchestrator.go | 19 +- .../services/gateway/session_transcript.go | 129 +++++++++ 22 files changed, 1396 insertions(+), 23 deletions(-) create mode 100644 internal/config/runtimeconfig/channels_persona_test.go create mode 100644 internal/runtime/loop/attribution.go create mode 100644 internal/runtime/loop/attribution_test.go create mode 100644 internal/services/channelbridge/persona.go create mode 100644 internal/services/channelbridge/persona_test.go create mode 100644 internal/services/gateway/admin_channel_persona_test.go create mode 100644 internal/services/gateway/session_attribution_test.go create mode 100644 internal/services/gateway/session_transcript.go diff --git a/Docs/api-client.md b/Docs/api-client.md index 8b5088e..756bb21 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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:` 由 `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=&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 历史消息按其说话角色出具 `[发言归属] 以下发言来自「<名字>」(),不是你说的。` 独立 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/.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。 diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index 479eb62..a2ca419 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -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. diff --git a/cmd/cyrene/persona_prompt.go b/cmd/cyrene/persona_prompt.go index 3f868b8..552a84c 100644 --- a/cmd/cyrene/persona_prompt.go +++ b/cmd/cyrene/persona_prompt.go @@ -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. diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 182ef2e..7c4d69a 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 99cf349..e51db5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/runtimeconfig/channels_persona_test.go b/internal/config/runtimeconfig/channels_persona_test.go new file mode 100644 index 0000000..ab583bf --- /dev/null +++ b/internal/config/runtimeconfig/channels_persona_test.go @@ -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) + } +} diff --git a/internal/runtime/loop/attribution.go b/internal/runtime/loop/attribution.go new file mode 100644 index 0000000..5e480db --- /dev/null +++ b/internal/runtime/loop/attribution.go @@ -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 + ")" +} diff --git a/internal/runtime/loop/attribution_test.go b/internal/runtime/loop/attribution_test.go new file mode 100644 index 0000000..49f6614 --- /dev/null +++ b/internal/runtime/loop/attribution_test.go @@ -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) + } + } +} diff --git a/internal/runtime/loop/loop.go b/internal/runtime/loop/loop.go index 1f8a720..27063bf 100644 --- a/internal/runtime/loop/loop.go +++ b/internal/runtime/loop/loop.go @@ -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, diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index b5b8460..811bda3 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -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 } diff --git a/internal/runtime/loop/options.go b/internal/runtime/loop/options.go index c615e25..a0d25bf 100644 --- a/internal/runtime/loop/options.go +++ b/internal/runtime/loop/options.go @@ -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 diff --git a/internal/runtime/loop/sessionenv.go b/internal/runtime/loop/sessionenv.go index 8cf86cd..5bf932e 100644 --- a/internal/runtime/loop/sessionenv.go +++ b/internal/runtime/loop/sessionenv.go @@ -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 { diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index b4d600b..7ea98ac 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -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 diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index 8ef56b6..44468ef 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -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) diff --git a/internal/services/channelbridge/persona.go b/internal/services/channelbridge/persona.go new file mode 100644 index 0000000..4f49544 --- /dev/null +++ b/internal/services/channelbridge/persona.go @@ -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) +} diff --git a/internal/services/channelbridge/persona_test.go b/internal/services/channelbridge/persona_test.go new file mode 100644 index 0000000..373276d --- /dev/null +++ b/internal/services/channelbridge/persona_test.go @@ -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) + } +} diff --git a/internal/services/channelbridge/reload.go b/internal/services/channelbridge/reload.go index 7ac2770..501b78b 100644 --- a/internal/services/channelbridge/reload.go +++ b/internal/services/channelbridge/reload.go @@ -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) } diff --git a/internal/services/gateway/admin_channel_persona_test.go b/internal/services/gateway/admin_channel_persona_test.go new file mode 100644 index 0000000..651b767 --- /dev/null +++ b/internal/services/gateway/admin_channel_persona_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index fbc7b70..699e7b6 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -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, } } diff --git a/internal/services/gateway/session_attribution_test.go b/internal/services/gateway/session_attribution_test.go new file mode 100644 index 0000000..8141e05 --- /dev/null +++ b/internal/services/gateway/session_attribution_test.go @@ -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) + } +} diff --git a/internal/services/gateway/session_orchestrator.go b/internal/services/gateway/session_orchestrator.go index a062c21..5943287 100644 --- a/internal/services/gateway/session_orchestrator.go +++ b/internal/services/gateway/session_orchestrator.go @@ -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] diff --git a/internal/services/gateway/session_transcript.go b/internal/services/gateway/session_transcript.go new file mode 100644 index 0000000..aea529a --- /dev/null +++ b/internal/services/gateway/session_transcript.go @@ -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 +}