Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0b4c941e4 | ||
|
|
c9da488749 | ||
|
|
39ae883a4e | ||
|
|
1347a77d33 | ||
|
|
f84a358e30 | ||
|
|
07df06b86d | ||
|
|
b040f588b2 | ||
|
|
e8bc8727da | ||
|
|
b626b3f5c0 | ||
|
|
50cc60d019 | ||
|
|
a1c9da6e8a | ||
|
|
e39cc5d096 | ||
|
|
5a69d6e0ff | ||
|
|
8058660537 | ||
|
|
e0793debc3 | ||
|
|
28a6cd7e53 | ||
|
|
5099c21520 | ||
|
|
a5b3d40a74 | ||
|
|
d47185feb8 | ||
|
|
656c35f1e8 | ||
|
|
921938d084 | ||
|
|
76263d42ed | ||
|
|
969857682f | ||
|
|
604f9ebaa9 | ||
|
|
bfbf5057bc | ||
|
|
7fbb8d6232 | ||
|
|
d6493d79f4 | ||
|
|
1b2576ecaf | ||
|
|
2702777950 | ||
|
|
82e9213804 | ||
|
|
996201c4ed | ||
|
|
c7c0294a16 | ||
|
|
3a0cc64160 | ||
|
|
3b9ae3f918 | ||
|
|
fe3872ee28 | ||
|
|
4224fc2b12 | ||
|
|
4e9cd17fbd | ||
|
|
fb55769cd7 | ||
|
|
67d7e4bd1b | ||
|
|
98e1ae6aa9 | ||
|
|
92b0f89492 | ||
|
|
91101dedfb | ||
|
|
0ec9cbe32b | ||
|
|
b955a27479 | ||
|
|
faf18afb91 | ||
|
|
5c6f4e50a0 | ||
|
|
6906d274fc | ||
|
|
5717735da9 | ||
|
|
3001fcc716 | ||
|
|
4c187ac070 | ||
|
|
af61b8ccfe | ||
|
|
6be76f1665 | ||
|
|
3157abcf3c | ||
|
|
fa5d3d23bf |
No files matched your search
+65
-24
@@ -1,12 +1,12 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.95`**(上一版 `v0.94`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.96`**(上一版 `v0.95`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
||||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
|
||||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
|
||||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
|
||||
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
|
||||
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
|
||||
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
|
||||
@@ -37,9 +37,9 @@
|
||||
|
||||
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
|
||||
|
||||
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
|
||||
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
|
||||
|
||||
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
|
||||
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
|
||||
|
||||
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
|
||||
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
|
||||
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
|
||||
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text,voice?,format?,instruction?}` → **`200` 原始音频字节**(`Content-Type: audio/<format>`,**非 JSON 信封**)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
|
||||
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
|
||||
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
|
||||
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
|
||||
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
|
||||
@@ -375,7 +375,7 @@
|
||||
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
|
||||
```
|
||||
|
||||
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。
|
||||
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
|
||||
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
|
||||
- 回放是尽力而为:存储未接或读出错静默跳过。
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
|
||||
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
|
||||
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
|
||||
> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。
|
||||
> **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
|
||||
|
||||
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
|
||||
|
||||
@@ -659,7 +659,7 @@
|
||||
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
|
||||
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
|
||||
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
|
||||
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。
|
||||
- ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
|
||||
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
|
||||
|
||||
**`documents` 数组语义(核心)**:
|
||||
@@ -713,10 +713,11 @@
|
||||
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
|
||||
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
|
||||
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
|
||||
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
|
||||
|
||||
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
|
||||
|
||||
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
|
||||
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
|
||||
|
||||
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
|
||||
|
||||
@@ -1537,7 +1538,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
|
||||
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
|
||||
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
|
||||
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | `POST /audio/speech` 当前遵循的服务端配置(请求体 `voice`/`format` 为前向兼容接受但暂不生效) |
|
||||
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
|
||||
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
|
||||
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
|
||||
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
|
||||
@@ -1582,7 +1583,47 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.95(当前契约版本)
|
||||
### v0.97(当前契约版本)
|
||||
|
||||
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
|
||||
|
||||
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
|
||||
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
|
||||
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
|
||||
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
|
||||
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
|
||||
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
|
||||
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
|
||||
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
|
||||
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
|
||||
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
|
||||
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
|
||||
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
|
||||
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
|
||||
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
|
||||
|
||||
### v0.96
|
||||
|
||||
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
|
||||
|
||||
- **`POST /api/v1/audio/speech` 新增可选 `sessionId`**(纯增量,`omitempty`)。发音人不再由请求体决定,而是按该会话的**生效人格**解析:**会话绑定 → 全局默认人格 → 空**。
|
||||
- 🔴 **省略 `sessionId` 不等于「没有角色」**:此时按**全局默认人格**选发音人(常规安装必有全局默认人格 ⇒ 仍走 `personas` 映射)。只有**连全局默认人格都没有**时才落到 `voice-tts.json` 的 `default`(没配则不合成)。
|
||||
- ⚠️ **不要**写成「`sessionId` 为空 ⇒ 发音人置空」——那会让本口永远用 `default` 那个声音,正是「换了人格还用老角色」的病害。
|
||||
- 请求体的 `voice` 仍**接受但不生效**(前向兼容);音色只能由服务端 `personas` 映射决定。
|
||||
- 产物**恒为 WAV**(服务端无转码),实际格式一律按**字节嗅探**判定,客户端**不要**照抄 `Content-Type`。请求体 `format` 仅为前向兼容接受。
|
||||
- **新增两个错误码**(状态码仍是 `500` —— 这是**服务端配置缺失**,不是客户端的错):
|
||||
| `error.code` | 含义 |
|
||||
| --- | --- |
|
||||
| `tts_persona_unmapped` | 这个生效人格在 `personas` 里没有映射(去面板补) |
|
||||
| `tts_no_character` | 连生效人格都没有,且没配 `default` |
|
||||
| `tts_failed` | 其余合成失败(原口径) |
|
||||
- 🔴 **管理面两条读端点是「脱敏投影」,不是原始数据**(M42):
|
||||
- `GET /api/v1/admin/observability/logs`(A10):每行 `msg` 先过凭据打码,**上限 4096 rune**(够一行日志全文,但不会把 MiB 级行塞进 JSON)。`time`/`level`/`source` **原样**,条目数与最新优先序不变。
|
||||
- `GET /api/v1/admin/observability/trace`(A11):每个 span 先过 `SanitizeRecord` ⇒ `props` 变**白名单投影**,表外键折成 `omitted: {键名 → {bytes, runes, sha256 前缀}}`(**仍能回答「这次输出了多大、两次是不是同一份」**);事件里按定义是正文的键同样折成 Digest。**字段名与帧形状不变**,客户端**不得依赖**未声明的 props。
|
||||
- `id/name/kind/status/startedAt/endedAt/durationNs`、`level/source/time`、条数与排序——**排障字段一律保留**。
|
||||
|
||||
### v0.95
|
||||
|
||||
|
||||
**「渠道静默窗口可写」版 —— 群里「一鼓作气打三句」只回一次,且参数在面板上调(切片 M23)**
|
||||
|
||||
@@ -1816,7 +1857,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
|
||||
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
|
||||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
|
||||
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
|
||||
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
|
||||
|
||||
### v0.79(上一版)
|
||||
|
||||
@@ -1824,11 +1865,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
|
||||
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
|
||||
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
|
||||
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
|
||||
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
|
||||
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
|
||||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
|
||||
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
|
||||
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
|
||||
|
||||
### v0.78(上一版)
|
||||
|
||||
@@ -1839,10 +1880,10 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
|
||||
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
|
||||
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
|
||||
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
|
||||
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
|
||||
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
|
||||
- **破坏性**:**无**。
|
||||
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
|
||||
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
|
||||
|
||||
### v0.77(上一版)
|
||||
|
||||
@@ -1972,11 +2013,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
|
||||
|
||||
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
|
||||
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
|
||||
|
||||
- **纯增量**:
|
||||
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
|
||||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(见 §3.2、§3.4.4、§4.4 覆盖率的「尚未填充」)。
|
||||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
|
||||
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
|
||||
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
|
||||
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
|
||||
@@ -2071,7 +2112,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
### 7.1 在飞切片
|
||||
|
||||
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
|
||||
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
|
||||
|
||||
| 项 | 状态 | 冻结位置 |
|
||||
|---|---|---|
|
||||
@@ -2096,7 +2137,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
| 项 | 现状 |
|
||||
|---|---|
|
||||
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
|
||||
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
|
||||
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
|
||||
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
|
||||
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
|
||||
@@ -2129,7 +2170,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
|
||||
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
|
||||
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
|
||||
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。
|
||||
- `POST /audio/speech` 的请求体 `voice`/`format` **不生效**;**v0.96 起发音人不再是服务端 env 决定**,而是按 `sessionId` 的**生效人格** → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射(见 §6 v0.96)。产物恒 WAV。
|
||||
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
|
||||
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
|
||||
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
|
||||
@@ -2140,7 +2181,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
|
||||
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
|
||||
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
|
||||
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。
|
||||
- **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
|
||||
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
|
||||
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
|
||||
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
|
||||
@@ -2194,7 +2235,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
|
||||
|
||||
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
|
||||
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
|
||||
|
||||
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
|
||||
|
||||
@@ -2202,4 +2243,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
|
||||
---
|
||||
|
||||
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`interLine truncated
|
||||
*本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。*
|
||||
@@ -125,6 +125,13 @@ func run() error {
|
||||
return fmt.Errorf("start gateway: %w", err)
|
||||
}
|
||||
|
||||
// emotionTTS 自启(切片 M47):**在网关 Listen 之后**才挂 —— 网关的
|
||||
// ListenAndServe 已交给自己的 goroutine,这里只投递一次「探活 → 必要时拉起」,
|
||||
// 整段(毫秒级探活 + 起一个 .bat)都在后台跑,因此**不占**后端启动时长:走到
|
||||
// 这里时进程已经可以服务。开关默认关(面板文件 voice-tts-launch.json),
|
||||
// 失败只 WARN,绝不影响后端启动与退出。
|
||||
a.startVoiceTTSLaunch(ctx)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("cyrene signal received, shutting down")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// 本文件把**后端启动时顺带照顾 emotionTTS**(切片 M47)接进组合根,并守住三条
|
||||
// 红线(塔 2026-10 已定,主人原话见 internal/runtime/voice/ttslaunch 包注释):
|
||||
//
|
||||
// 1. **默认关**:面板文件 `$DATA_DIR/config/voice-tts-launch.json` 没写
|
||||
// `{"enabled": true}` 时,这里**连探活都不发**、也不构造任何后台任务 ——
|
||||
// 主人早前说过「开机自启先不急 后面做」。
|
||||
// 2. **不占启动时长**:`Trigger` 把探活 + 起脚本整段丢进后台 goroutine 后立即
|
||||
// 返回,调用点在网关 Listen 之后(见 main.run);探活超时/脚本拉起耗时都
|
||||
// 落不到后端的启动路径上。
|
||||
// 3. **失败只 WARN**:脚本不存在、起进程失败、探活超时都只是带 source 的日志,
|
||||
// 本函数**没有 error 返回**,绝不阻塞或改坏后端启动。
|
||||
// 4. **探活地址单向回退**(塔 2026-10-26 裁决,见 resolveProbeURL):launch 文件
|
||||
// 显式写了 `probeUrl` 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者
|
||||
// 都空才是默认 9880。反向(拿这里的地址去改合成面)一律不做。
|
||||
//
|
||||
// 零新 `.env` 键(`.env` 已冻结):开关与脚本路径都走面板文件家族
|
||||
// (`runtimeconfig`,与 `voice-tts.json` 同一套读取口径)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// voiceTTSLaunchSource 是本切片日志的来源标签(AGENTS §10:日志必须标来源)。
|
||||
const voiceTTSLaunchSource = "cmd/cyrene/voice-tts-launch"
|
||||
|
||||
// startVoiceTTSLaunch 读面板配置,按需投递**一次**「探活 → 必要时拉起」。
|
||||
//
|
||||
// 返回 nil 表示这一级没启用(没配过 / 显式 false / 文件坏掉),调用方据此什么都
|
||||
// 不做;返回非 nil 仅为了让调用方在关闭期或验收里能 `Wait()`(生产路径忽略它)。
|
||||
func (a *app) startVoiceTTSLaunch(ctx context.Context) *ttslaunch.Launcher {
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(resolveDataDir(a.cfg))
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSLaunchTuning(path)
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ **不启用**:一份读不懂的配置绝不能变成"随手拉起一个进程"。
|
||||
log.Warn("voice tts launch config unreadable, emotionTTS auto-launch stays off",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "err", err)
|
||||
return nil
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
log.Debug("emotionTTS auto-launch not enabled in panel config",
|
||||
"path", runtimeconfig.VoiceTTSLaunchConfigRelPath, "hint", `set {"enabled": true}`)
|
||||
return nil
|
||||
}
|
||||
resolved := a.resolveProbeURL(tuning.ProbeURL)
|
||||
if err := validateResolvedProbeURL(resolved); err != nil {
|
||||
// 🔴 写错地址(漏 scheme / 主机拼错)会让"没起"永远成立 ⇒ **每次启动都拉一次
|
||||
// 进程**。宁可不启用,也不做这种自动重试机器(复审 P2-2b)。
|
||||
log.Warn("emotionTTS auto-launch stays off: invalid probe url",
|
||||
"probe_url", resolved.url,
|
||||
"probe_url_source", resolved.probeSource,
|
||||
"err", err)
|
||||
return nil
|
||||
}
|
||||
launcher := ttslaunch.New(withProbeURL(voiceTTSLaunchConfig(tuning), resolved), ttslaunch.WithLogger(log))
|
||||
cfg := launcher.Config()
|
||||
log.Info("emotionTTS auto-launch enabled",
|
||||
"probe_url", cfg.ProbeURL,
|
||||
// 探活地址可能来自三处,日志必须**同时**给出地址与来源:主人改端口后
|
||||
// 一眼能看出实际问的是哪个地址、以及它是显式写的还是兜底来的。
|
||||
"probe_url_source", resolved.probeSource,
|
||||
"script", cfg.ScriptPath,
|
||||
"probe_timeout_ms", cfg.ProbeTimeout.Milliseconds(),
|
||||
"cooldown_sec", int(cfg.Cooldown/time.Second),
|
||||
"config", runtimeconfig.VoiceTTSLaunchConfigRelPath)
|
||||
// 立即返回:探活与起脚本都在后台跑(见 ttslaunch.Launcher.Trigger)。
|
||||
launcher.Trigger(ctx)
|
||||
return launcher
|
||||
}
|
||||
|
||||
// probeResolution 是"探活地址 + 它的来源"(来源只用于日志,绝不参与判定)。
|
||||
type probeResolution struct {
|
||||
url string
|
||||
probeSource string
|
||||
}
|
||||
|
||||
// 探活地址的三个来源标签(写进启动 Info 行,供主人一眼核对)。
|
||||
const (
|
||||
probeSourceLaunchConfig = "voice-tts-launch.json"
|
||||
probeSourceVoiceTTS = "voice-tts.json"
|
||||
probeSourceDefault = "default"
|
||||
)
|
||||
|
||||
// resolveProbeURL 决定这次到底探哪个地址 —— **单向**回退,且只在组合根发生:
|
||||
//
|
||||
// 1. `voice-tts-launch.json` 显式写了 `probeUrl` ⇒ 用它(**永不**被别的键压过);
|
||||
// 2. 没写 ⇒ 兜底用 `voice-tts.json` 的 `baseUrl`(同一个服务,主人换端口只该改
|
||||
// 一处;这正是"两处一起改"的 footgun 的收口);
|
||||
// 3. 两者都没写(或 voice-tts.json 坏了)⇒ 空串,由 `ttslaunch.Config.Normalized`
|
||||
// 补默认 `http://127.0.0.1:9880`。
|
||||
//
|
||||
// 🔴 只做这一向:反向(拿 launch 的地址去改 `voice-tts.json` 的 `baseUrl`,或用它
|
||||
// 顶替合成地址)一律不做 —— 合成面与"起没起"是两个问题,前者不能被后者的配置影响。
|
||||
func (a *app) resolveProbeURL(explicit string) probeResolution {
|
||||
if url := strings.TrimSpace(explicit); url != "" {
|
||||
return probeResolution{url: url, probeSource: probeSourceLaunchConfig}
|
||||
}
|
||||
log := logger.WithSource(a.log, voiceTTSLaunchSource)
|
||||
tuning, err := runtimeconfig.LoadVoiceTTSTuning(runtimeconfig.DefaultVoiceTTSPath(resolveDataDir(a.cfg)))
|
||||
if err != nil {
|
||||
// 坏文件 ⇒ 这里只回落默认地址(**不**因此关掉自启:那是 launch 文件自己的
|
||||
// 事),并且必须说清楚"我没读到 baseUrl"。
|
||||
log.Warn("voice tts config unreadable, probe url falls back to default",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "err", err)
|
||||
return probeResolution{probeSource: probeSourceDefault}
|
||||
}
|
||||
if base := strings.TrimSpace(tuning.BaseURL); base != "" {
|
||||
log.Debug("probe url inherited from voice tts config",
|
||||
"path", runtimeconfig.VoiceTTSConfigRelPath, "base_url", base)
|
||||
return probeResolution{url: base, probeSource: probeSourceVoiceTTS}
|
||||
}
|
||||
return probeResolution{probeSource: probeSourceDefault}
|
||||
}
|
||||
|
||||
// withProbeURL 把解析出的探活地址填进 launcher 配置(空串留给 Normalized 补默认)。
|
||||
func withProbeURL(cfg ttslaunch.Config, r probeResolution) ttslaunch.Config {
|
||||
cfg.ProbeURL = r.url
|
||||
return cfg
|
||||
}
|
||||
|
||||
// validateResolvedProbeURL 校验**最终要探的那个地址**(空串 = 两个文件都没给,由
|
||||
// Normalized 补默认 9880,默认值本身合法,这里不重复校验)。
|
||||
//
|
||||
// 它和 `ttslaunch.New` 里的那道校验是同一把闸,但这里多做一件事:WARN 带上
|
||||
// `probe_url_source` —— 主人一眼能看出该改哪个文件(launch 文件的 probeUrl,
|
||||
// 还是 voice-tts.json 的 baseUrl)。
|
||||
func validateResolvedProbeURL(r probeResolution) error {
|
||||
if r.url == "" {
|
||||
return nil
|
||||
}
|
||||
return ttslaunch.ValidateProbeURL(r.url)
|
||||
}
|
||||
|
||||
// voiceTTSLaunchConfig 把面板形状映射成 launcher 的配置。
|
||||
//
|
||||
// 它**不**在这里裁决缺省值(脚本路径/探活地址/超时/冷却的默认值全在
|
||||
// `ttslaunch.Config.Normalized` 一处):零值原样传下去,由那一处补齐。
|
||||
func voiceTTSLaunchConfig(t runtimeconfig.VoiceTTSLaunchTuning) ttslaunch.Config {
|
||||
return ttslaunch.Config{
|
||||
Enabled: t.EnabledOn(),
|
||||
ScriptPath: t.ScriptPath,
|
||||
ProbeURL: t.ProbeURL,
|
||||
ProbeTimeout: time.Duration(t.ProbeTimeoutMs) * time.Millisecond,
|
||||
Cooldown: time.Duration(t.CooldownSec) * time.Second,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定复审 P2-2 在**组合根**上的两道闸:
|
||||
//
|
||||
// 1. **关闭期取消 ⇒ 0 次拉起**(P2-2a):后端关闭时 ctx 被取消,探活随之返回
|
||||
// `context.Canceled`,这必须是"跳过",绝不能在关闭过程中拉起 TTS;
|
||||
// 2. **探活地址非法 ⇒ 不启用、一次都不拉**(P2-2b):`probeUrl` 与 `voice-tts.json`
|
||||
// 的 `baseUrl` 兜底**两个来源**都要被挡(漏一个就等于绕过闸门,变成"每次启动
|
||||
// 都拉一次进程")。
|
||||
//
|
||||
// 用例除了看返回值,还会真等一会儿确认没有进程被拉起(拉起是异步的,脚本一跑
|
||||
// 就会落 marker 文件);探活用黑洞监听(只接连接不回话),保证取消一定落在探活
|
||||
// 进行中。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch 钉住"关闭期取消 ⇒ 0 次拉起"。
|
||||
//
|
||||
// 变异点:去掉 ttslaunch 里 `errors.Is(probeErr, context.Canceled)` 那个跳过分支 ⇒
|
||||
// 取消被当成"没起" ⇒ 脚本被拉起(marker 出现),本用例红。
|
||||
func TestStartVoiceTTSLaunch_ShutdownCancelDoesNotLaunch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":5000,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", script))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
launcher := a.startVoiceTTSLaunch(ctx)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
// 模拟后端关闭:探活还挂在黑洞上时取消。
|
||||
cancel()
|
||||
launcher.Wait()
|
||||
|
||||
assertNotLaunched(t, marker)
|
||||
if out := buf.String(); strings.Contains(out, "emotionTTS launch failed") {
|
||||
t.Fatalf("关闭期取消被记成了拉起失败: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff 钉住第一道闸:launch 文件里写了
|
||||
// 非法 `probeUrl` ⇒ 返回 nil(不构造 launcher)、WARN 标出源、**一次都不拉**。
|
||||
//
|
||||
// 变异点:去掉 `validateResolvedProbeURL` ⇒ 返回非 nil,且探活每轮必败 ⇒ 脚本被
|
||||
// 拉起(marker 出现),两条断言都会红。
|
||||
func TestStartVoiceTTSLaunch_InvalidProbeURLStaysOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":"127.0.0.1:9880","scriptPath":%q}`, script))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher != nil {
|
||||
t.Fatal("非法 probeUrl 仍启用了自启(会每次启动都拉一次进程)")
|
||||
}
|
||||
assertNotLaunched(t, marker)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法 probeUrl 没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||||
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff 钉住第二道闸:launch 文件
|
||||
// 没写 probeUrl、而 `voice-tts.json` 的 baseUrl 非法 ⇒ 同样不启用、一次都不拉。
|
||||
func TestStartVoiceTTSLaunch_InvalidBaseURLFallbackStaysOff(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
script, marker := markerBat(t, dir)
|
||||
writeVoiceTTSPanel(t, a, `{"enabled":true,"baseUrl":"127.0.0.1:9880"}`)
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`, script))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher != nil {
|
||||
t.Fatal("baseUrl 兜底来的非法地址也没被挡住(每次启动都会拉一次进程)")
|
||||
}
|
||||
assertNotLaunched(t, marker)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法 baseUrl 没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||||
t.Fatalf("WARN 没标出该改哪个文件: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateResolvedProbeURL_EmptyMeansDefault 钉住"空串不在这里判死":两个文件
|
||||
// 都没给地址时交给 Normalized 补 9880(默认值本身合法)。
|
||||
func TestValidateResolvedProbeURL_EmptyMeansDefault(t *testing.T) {
|
||||
if err := validateResolvedProbeURL(probeResolution{url: "", probeSource: probeSourceDefault}); err != nil {
|
||||
t.Fatalf("空地址应交给默认值: %v", err)
|
||||
}
|
||||
if err := validateResolvedProbeURL(probeResolution{url: ttslaunch.DefaultProbeURL}); err != nil {
|
||||
t.Fatalf("默认地址必须合法: %v", err)
|
||||
}
|
||||
if err := validateResolvedProbeURL(probeResolution{url: "localhost:9880"}); err == nil {
|
||||
t.Fatal("漏 scheme 的地址被放过了")
|
||||
}
|
||||
}
|
||||
|
||||
// markerBat 造一个"跑起来就落 marker"的 .bat,用于观察"到底有没有进程被拉起"。
|
||||
// 非 Windows 返回空串(那台机器上这个脚本本来也拉不起来),调用方据此跳过观察。
|
||||
func markerBat(t *testing.T, dir string) (script, marker string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "windows" {
|
||||
return "", ""
|
||||
}
|
||||
script = filepath.Join(dir, "一键启动.bat")
|
||||
marker = filepath.Join(dir, "launched.txt")
|
||||
body := fmt.Sprintf("@echo off\r\necho launched> \"%s\"\r\n", marker)
|
||||
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
return script, marker
|
||||
}
|
||||
|
||||
// assertNotLaunched 等一小会儿确认脚本**没有**被拉起(拉起是异步的,所以不能只看
|
||||
// 返回值;空 marker = 非 Windows,跳过观察)。
|
||||
func assertNotLaunched(t *testing.T, marker string) {
|
||||
t.Helper()
|
||||
if marker == "" {
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatalf("脚本被拉起了: %s", marker)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
// 本文件锁定切片 M47 在**组合根**上的口径(都是"改坏就静默开错/静默拖慢启动"的那种):
|
||||
//
|
||||
// 1. 面板文件没配过 / 写了 `enabled:false` ⇒ **不启用**(返回 nil),连探活都不发;
|
||||
// 2. 启用后 `startVoiceTTSLaunch` **立即返回**,即使探活会挂住整整一个超时 ——
|
||||
// 证明 TTS 的延迟不计入后端启动时长(它是唯一"启动耗时"可测的量);
|
||||
// 3. 拉不起来(脚本不存在)⇒ 只有 WARN,**没有任何 error 回流**到启动路径。
|
||||
// 4. 探活地址**单向**回退(塔 2026-10-26 裁决):launch 文件显式写了 `probeUrl`
|
||||
// 就以它为准;没写则用 `voice-tts.json` 的 `baseUrl`;两者都空才是默认 9880。
|
||||
// 启动 Info 行必须同时给出地址与来源。
|
||||
//
|
||||
// 它用 `DefaultVoiceTTSLaunchPath` 的真实落点
|
||||
// ($DATA_DIR/config/voice-tts-launch.json)跑,因此也钉住"零新 .env 键"。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/ttslaunch"
|
||||
)
|
||||
|
||||
// captureBuf 是并发安全的日志缓冲(WARN 由后台 goroutine 写)。
|
||||
type captureBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *captureBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *captureBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// launchTestApp 造一个只够本题用的 app(数据目录 + 收日志的 logger)。
|
||||
func launchTestApp(t *testing.T, dataDir string) (*app, *captureBuf) {
|
||||
t.Helper()
|
||||
buf := &captureBuf{}
|
||||
log := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
return &app{cfg: &config.Config{DataDir: dataDir}, log: log}, buf
|
||||
}
|
||||
|
||||
// writeLaunchPanel 把面板文件写到真实落点($DATA_DIR/config/voice-tts-launch.json)。
|
||||
func writeLaunchPanel(t *testing.T, dataDir, body string) {
|
||||
t.Helper()
|
||||
path := runtimeconfig.DefaultVoiceTTSLaunchPath(dataDir)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write panel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_DefaultOff 钉住"默认关":没配过 ⇒ 不构造、不探活、不拉起。
|
||||
func TestStartVoiceTTSLaunch_DefaultOff(t *testing.T) {
|
||||
a, buf := launchTestApp(t, t.TempDir())
|
||||
start := time.Now()
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("没配过却启用了自启")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("默认关也花了 %v", elapsed)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "not enabled") {
|
||||
t.Fatalf("没留一条「没启用」的痕迹: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitFalse 钉住"显式 false 同样一次都不拉"。
|
||||
func TestStartVoiceTTSLaunch_ExplicitFalse(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeLaunchPanel(t, dir, `{"enabled":false}`)
|
||||
a, _ := launchTestApp(t, dir)
|
||||
if l := a.startVoiceTTSLaunch(context.Background()); l != nil {
|
||||
t.Fatal("enabled:false 却启用了自启")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ReturnsBeforeProbe 是本切片的"启动耗时"证明:
|
||||
// 探活会挂住一个 800ms 的超时(对端只接连接不回话),而挂载动作必须在毫秒级返回。
|
||||
//
|
||||
// 同时它顺带钉住第 3 条:脚本不存在 ⇒ 后台只记 WARN,`startVoiceTTSLaunch` 自己
|
||||
// 没有任何 error 可返回(签名里就没有),后端启动路径不受影响。
|
||||
func TestStartVoiceTTSLaunch_ReturnsBeforeProbe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blackhole, addr := blackholeListener(t)
|
||||
defer blackhole.Close()
|
||||
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(
|
||||
`{"enabled":true,"probeUrl":%q,"probeTimeoutMs":800,"scriptPath":%q}`,
|
||||
"http://"+addr+"/", filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
start := time.Now()
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Fatalf("挂载花了 %v —— 探活被同步等在了启动路径上", elapsed)
|
||||
}
|
||||
// 后台那次探活会等到 800ms 超时,然后去拉一个不存在的脚本。
|
||||
launcher.Wait()
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "level=WARN") || !strings.Contains(out, "backend startup unaffected") {
|
||||
t.Fatalf("拉起失败没有只记 WARN: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceTTSLaunchConfig_MapsTuning 钉住"面板字段 → launcher 配置"的映射:
|
||||
// 缺省值不由组合根裁决(空字段原样传下去,由 ttslaunch.Config.Normalized 补)。
|
||||
func TestVoiceTTSLaunchConfig_MapsTuning(t *testing.T) {
|
||||
enabled := true
|
||||
cfg := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{
|
||||
Enabled: &enabled,
|
||||
ScriptPath: `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,
|
||||
ProbeURL: "http://127.0.0.1:9880",
|
||||
ProbeTimeoutMs: 250,
|
||||
CooldownSec: 90,
|
||||
})
|
||||
if !cfg.Enabled {
|
||||
t.Fatal("enabled 没映射过来")
|
||||
}
|
||||
if cfg.ProbeTimeout != 250*time.Millisecond || cfg.Cooldown != 90*time.Second {
|
||||
t.Fatalf("超时/冷却没映射: %+v", cfg)
|
||||
}
|
||||
if cfg.ScriptPath != ttslaunch.DefaultScriptPath || cfg.ProbeURL != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("路径/地址没映射: %+v", cfg)
|
||||
}
|
||||
|
||||
// 空配置 ⇒ 不启用,且一个缺省值都不在这里填(Normalized 才是唯一裁决处)。
|
||||
if empty := voiceTTSLaunchConfig(runtimeconfig.VoiceTTSLaunchTuning{}); empty.Enabled {
|
||||
t.Fatal("空配置不该启用")
|
||||
} else if empty.ScriptPath != "" || empty.ProbeURL != "" || empty.ProbeTimeout != 0 {
|
||||
t.Fatalf("组合根不该替 launcher 填缺省值: %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// blackholeListener 起一个"只接连接、绝不回话"的回环监听:探活会一直等到自己的
|
||||
// 超时,正好用来证明挂载动作没有同步等它。
|
||||
func blackholeListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
var conns []net.Conn
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
conns = append(conns, c)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
return ln, ln.Addr().String()
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS 钉住**单向回退**的第一向:
|
||||
// launch 文件没写 `probeUrl` 时,用 `voice-tts.json` 的 `baseUrl`(主人换端口只该
|
||||
// 改一处)。
|
||||
//
|
||||
// 它不只比字符串 —— 真让探活打到那个端口(hits > 0):把回退摘掉,探活就会跑去
|
||||
// 默认 9880,本用例立刻红。
|
||||
func TestStartVoiceTTSLaunch_ProbeURLInheritedFromVoiceTTS(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
voiceTTS, hits := countingServer(t)
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
// 合成面把端口改成了自定义端口;自启面**没写** probeUrl。
|
||||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q,"personas":{"cyrene":"char_1"}}`, voiceTTS.URL))
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != voiceTTS.URL {
|
||||
t.Fatalf("探活地址 = %q, want voice-tts.json 的 baseUrl %q", got, voiceTTS.URL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if n := atomic.LoadInt32(hits); n == 0 {
|
||||
t.Fatal("探活没打到 voice-tts.json 的 baseUrl 端口(回退失效)")
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts.json") {
|
||||
t.Fatalf("启动 Info 行没标出地址是兜底来的: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault 钉住第二向:两个文件都没给
|
||||
// 地址 ⇒ 默认 `http://127.0.0.1:9880`,且日志标 `default`。
|
||||
func TestStartVoiceTTSLaunch_ProbeURLFallsBackToDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, buf := launchTestApp(t, dir)
|
||||
// 只有自启文件,没有 voice-tts.json。
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"scriptPath":%q}`,
|
||||
filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != ttslaunch.DefaultProbeURL {
|
||||
t.Fatalf("探活地址 = %q, want 默认 %q", got, ttslaunch.DefaultProbeURL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=default") {
|
||||
t.Fatalf("启动 Info 行没标出地址用的是默认值: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartVoiceTTSLaunch_ExplicitProbeURLWins 钉住"显式压过兜底":launch 文件写了
|
||||
// `probeUrl` ⇒ 用它,**永不**去问 voice-tts.json 的 baseUrl(后者一次都不该被探)。
|
||||
func TestStartVoiceTTSLaunch_ExplicitProbeURLWins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
explicit, explicitHits := countingServer(t)
|
||||
inherited, inheritedHits := countingServer(t)
|
||||
a, buf := launchTestApp(t, dir)
|
||||
|
||||
writeVoiceTTSPanel(t, a, fmt.Sprintf(`{"enabled":true,"baseUrl":%q}`, inherited.URL))
|
||||
writeLaunchPanel(t, dir, fmt.Sprintf(`{"enabled":true,"probeUrl":%q,"scriptPath":%q}`,
|
||||
explicit.URL, filepath.Join(dir, "不存在的一键启动.bat")))
|
||||
|
||||
launcher := a.startVoiceTTSLaunch(context.Background())
|
||||
if launcher == nil {
|
||||
t.Fatal("启用后应拿到 launcher")
|
||||
}
|
||||
if got := launcher.Config().ProbeURL; got != explicit.URL {
|
||||
t.Fatalf("探活地址 = %q, want 显式 %q", got, explicit.URL)
|
||||
}
|
||||
launcher.Wait()
|
||||
if atomic.LoadInt32(explicitHits) == 0 {
|
||||
t.Fatal("显式 probeUrl 没被用来探活")
|
||||
}
|
||||
if n := atomic.LoadInt32(inheritedHits); n != 0 {
|
||||
t.Fatalf("显式 probeUrl 仍去问了 voice-tts.json 的地址 %d 次", n)
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "probe_url_source=voice-tts-launch.json") {
|
||||
t.Fatalf("启动 Info 行没标出地址是显式配置的: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// countingServer 起一个"数请求次数"的 httptest 服务:用来证明探活**真的打到了**
|
||||
// 某个地址,而不只是配置里写着那个字符串。
|
||||
func countingServer(t *testing.T) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits
|
||||
}
|
||||
@@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool {
|
||||
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
|
||||
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
|
||||
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
|
||||
//
|
||||
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
|
||||
//
|
||||
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
|
||||
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
|
||||
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
|
||||
// 路径,adopt_test 钉着它必须可见);
|
||||
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
|
||||
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
|
||||
//
|
||||
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
|
||||
// 拼接处再洗一遍。
|
||||
func withChromeOutput(err error, preview string) error {
|
||||
if preview == "" {
|
||||
return fmt.Errorf("%w;浏览器没有输出任何内容", err)
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
|
||||
@@ -30,8 +33,28 @@ type cdpError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口。
|
||||
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
|
||||
// Error 渲染协议错误(实现 error 接口)。
|
||||
//
|
||||
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
|
||||
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
|
||||
// 可见视图,因此默认**不出镜**:
|
||||
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
|
||||
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
|
||||
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
|
||||
//
|
||||
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
|
||||
func (e *cdpError) Error() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "CDP %d", e.Code)
|
||||
switch token := trace.ErrorToken(e.Message); {
|
||||
case token != "":
|
||||
b.WriteString(": ")
|
||||
b.WriteString(token)
|
||||
case e.Message != "":
|
||||
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
|
||||
//
|
||||
|
||||
@@ -3,6 +3,7 @@ package browser
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
|
||||
// 判定「连接级问题 ⇒ 丢会话重起」)。
|
||||
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
|
||||
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
|
||||
//
|
||||
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
|
||||
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
|
||||
// (trace.ErrorToken)才保留,其余只留字节数。
|
||||
//
|
||||
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
|
||||
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
|
||||
// 守卫 —— 用例不该拿它当「自由文本」。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
|
||||
// ⇒ 哨兵文本出现在错误串里,本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
|
||||
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32000, Message: "boom"}
|
||||
return nil, &cdpError{Code: -32000, Message: message}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
|
||||
if err == nil {
|
||||
t.Fatal("协议错误应当返回 error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
|
||||
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP -32000") {
|
||||
t.Errorf("错误没带上协议码: %v", err)
|
||||
}
|
||||
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
|
||||
}
|
||||
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
|
||||
if !strings.Contains(err.Error(), "Runtime.evaluate") {
|
||||
t.Errorf("错误丢了方法名: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDP 调用失败") {
|
||||
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
|
||||
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
|
||||
//
|
||||
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
|
||||
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
|
||||
})
|
||||
wsURL, _ := pageTargetWS(context.Background(), f.base)
|
||||
c, err := newCDPClient(context.Background(), wsURL)
|
||||
if err != nil {
|
||||
t.Fatalf("newCDPClient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
|
||||
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
|
||||
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
|
||||
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package browser
|
||||
|
||||
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
|
||||
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
|
||||
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
|
||||
//
|
||||
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
|
||||
// 的凭据断言(以及日志读面那条)会全红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
|
||||
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
|
||||
// 普通原话一个字都不许少。
|
||||
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
|
||||
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
|
||||
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
|
||||
)
|
||||
var out chromeOutput
|
||||
for _, line := range []string{complaint, credURL, bearer} {
|
||||
if _, err := out.Write([]byte(line + "\n")); err != nil {
|
||||
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got := out.preview()
|
||||
t.Logf("实测预览=%q", got)
|
||||
|
||||
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
|
||||
}
|
||||
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
|
||||
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
|
||||
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
|
||||
// 量级)。没有它,读者会把半截输出误当成完整输出。
|
||||
func TestChromeOutputPreviewIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out chromeOutput
|
||||
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got := out.preview()
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
|
||||
}
|
||||
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
|
||||
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("字节截断时仍报总量", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out chromeOutput
|
||||
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
|
||||
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
got := out.preview()
|
||||
if !strings.Contains(got, "已截断,共 5000 字节") {
|
||||
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
|
||||
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
|
||||
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
|
||||
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
complaint = "不支持的标记: --remote-allow-origins=*"
|
||||
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
|
||||
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
|
||||
)
|
||||
log, buf := newCapturedLog(slog.LevelDebug)
|
||||
l := newFakeChrome(t, fakeBrowserScript{
|
||||
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
|
||||
}, 5*time.Second, log)
|
||||
|
||||
_, stop, err := l.Dial(context.Background())
|
||||
if stop != nil {
|
||||
t.Error("失败路径不该交出停止函数")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
|
||||
}
|
||||
if !errors.Is(err, ErrLaunch) {
|
||||
t.Fatalf("仍须包住 ErrLaunch: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
logs := buf.String()
|
||||
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
|
||||
|
||||
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
|
||||
}
|
||||
if strings.Contains(logs, secret) {
|
||||
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "[redacted]") {
|
||||
t.Errorf("打码应当看得见([redacted]): %v", err)
|
||||
}
|
||||
|
||||
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
|
||||
// 浏览器原话那半是这条出口存在的理由。
|
||||
for _, want := range []string{
|
||||
"绝不杀别人的浏览器", l.profileDir,
|
||||
complaint, "载入失败",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs, complaint) {
|
||||
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
|
||||
}
|
||||
}
|
||||
|
||||
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
|
||||
func tailRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[len(r)-n:])
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package browser
|
||||
|
||||
// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们,
|
||||
// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。
|
||||
//
|
||||
// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go
|
||||
// 的「按字段类别判定」):
|
||||
//
|
||||
// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码
|
||||
// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守:
|
||||
// 像枚举就保留,散文一律丢弃;
|
||||
// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以
|
||||
// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带
|
||||
// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断;
|
||||
// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本:
|
||||
// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。
|
||||
//
|
||||
// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段
|
||||
// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住
|
||||
// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。
|
||||
//
|
||||
// 为什么不复用既有的两个档位:
|
||||
// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 ——
|
||||
// `TypeError: Cannot read properties of undefined (reading 'addEventListener')`
|
||||
// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索;
|
||||
// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的;
|
||||
// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 ——
|
||||
// 而这条错误串同时进日志与模型可见视图。
|
||||
//
|
||||
// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时
|
||||
// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。
|
||||
const maxExceptionTextRunes = 200
|
||||
|
||||
// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。
|
||||
//
|
||||
// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含
|
||||
// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」
|
||||
// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议
|
||||
// 常量**,不是从对端抄来的文本,因此剥它是安全的。
|
||||
const netErrorNamespace = "net::"
|
||||
|
||||
// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。
|
||||
// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定
|
||||
// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。
|
||||
const netErrorCodePrefix = "ERR_"
|
||||
|
||||
// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。
|
||||
// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。
|
||||
func netErrorCode(raw string) string {
|
||||
text := strings.TrimSpace(raw)
|
||||
namespace := ""
|
||||
if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok {
|
||||
namespace, text = netErrorNamespace, rest
|
||||
}
|
||||
if !strings.HasPrefix(text, netErrorCodePrefix) {
|
||||
return ""
|
||||
}
|
||||
token := trace.ErrorToken(text)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
return namespace + token
|
||||
}
|
||||
|
||||
// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。
|
||||
// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。
|
||||
//
|
||||
// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」,
|
||||
// 不带那句话(与 cdpError.Error 同一口径)。
|
||||
func navigateCause(raw string) string {
|
||||
if code := netErrorCode(raw); code != "" {
|
||||
return code
|
||||
}
|
||||
return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw))
|
||||
}
|
||||
|
||||
// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行
|
||||
// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+
|
||||
// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。
|
||||
func pageExceptionText(raw string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes)
|
||||
}
|
||||
|
||||
// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。
|
||||
func evaluateCause(raw string) string {
|
||||
if text := pageExceptionText(raw); text != "" {
|
||||
return text
|
||||
}
|
||||
// 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。
|
||||
return "页面异常(对端未给文本)"
|
||||
}
|
||||
|
||||
// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600**
|
||||
// (= 12 行 × 50 rune)。
|
||||
//
|
||||
// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致
|
||||
// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`),
|
||||
// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。
|
||||
//
|
||||
// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下;
|
||||
// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的
|
||||
// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。
|
||||
//
|
||||
// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方
|
||||
// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600
|
||||
// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。
|
||||
const chromeOutputPreviewRunes = 600
|
||||
|
||||
// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 +
|
||||
// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的
|
||||
// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在
|
||||
// preview() 里,两个读面不会判得不一致。
|
||||
func chromeOutputPreview(text string) string {
|
||||
return trace.RedactTextLimited(text, chromeOutputPreviewRunes)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
|
||||
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
|
||||
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
|
||||
t.Helper()
|
||||
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable":
|
||||
return map[string]any{}, nil
|
||||
case "Page.navigate":
|
||||
return map[string]any{"errorText": errorText}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
|
||||
})
|
||||
}
|
||||
|
||||
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
|
||||
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
|
||||
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
|
||||
t.Helper()
|
||||
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||||
switch method {
|
||||
case "Page.enable", "Page.navigate":
|
||||
return map[string]any{}, nil
|
||||
case "Page.getFrameTree":
|
||||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||||
case "Page.createIsolatedWorld":
|
||||
return map[string]any{"executionContextId": 1}, nil
|
||||
case "Runtime.evaluate":
|
||||
expr, _ := params["expression"].(string)
|
||||
if strings.Contains(expr, "readyState") {
|
||||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||||
"url": "https://example.com/", "title": "t", "readyState": "complete",
|
||||
}}}, nil
|
||||
}
|
||||
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
|
||||
}
|
||||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||||
})
|
||||
}
|
||||
|
||||
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
|
||||
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
|
||||
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
|
||||
//
|
||||
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
|
||||
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
|
||||
t.Parallel()
|
||||
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
|
||||
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
|
||||
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrNavigate) {
|
||||
t.Fatalf("err = %v, want ErrNavigate", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "已隐去") {
|
||||
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
|
||||
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
|
||||
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
|
||||
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
|
||||
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, code := range []string{
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
"net::ERR_TIMED_OUT",
|
||||
"ERR_ABORTED",
|
||||
} {
|
||||
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrNavigate) {
|
||||
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), code) {
|
||||
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
|
||||
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
|
||||
// 文本的处置口径是「带上限地带」,见 errtext.go)。
|
||||
//
|
||||
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
|
||||
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
|
||||
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
|
||||
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrEvaluate) {
|
||||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "[redacted]") {
|
||||
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "TypeError") {
|
||||
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
|
||||
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
|
||||
// 就能在按行聚合的日志里伪造出另一条记录)。
|
||||
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
|
||||
t.Parallel()
|
||||
const tail = "TAILMARKER"
|
||||
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
|
||||
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
|
||||
|
||||
_, err := m.RenderText(context.Background(), "https://example.com/")
|
||||
if !errors.Is(err, ErrEvaluate) {
|
||||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, tail) {
|
||||
t.Errorf("超出上限的尾部不该出镜: %q", msg)
|
||||
}
|
||||
if !strings.HasSuffix(msg, "…") {
|
||||
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
|
||||
}
|
||||
if strings.ContainsAny(msg, "\r\n") {
|
||||
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
|
||||
// rune 加一个省略号,一个不多。
|
||||
func TestPageExceptionTextLimitsRunes(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
|
||||
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
|
||||
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("截断应补省略号: %q", got)
|
||||
}
|
||||
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
|
||||
short := pageExceptionText("TypeError: body is gone")
|
||||
if short != "TypeError: body is gone" {
|
||||
t.Errorf("未超限不该改动文本: %q", short)
|
||||
}
|
||||
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
|
||||
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
|
||||
t.Errorf("空白异常文本的处理 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
|
||||
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
|
||||
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
|
||||
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
|
||||
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
|
||||
{"ERR_FAILED", "ERR_FAILED"},
|
||||
{"hunter2", ""},
|
||||
{"Failed loading https://example.com/", ""},
|
||||
{"net::ERR_NOT_A_CODE because spaces", ""},
|
||||
{"net::hunter2", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := netErrorCode(c.in); got != c.want {
|
||||
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,12 @@ const (
|
||||
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
|
||||
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
|
||||
//
|
||||
// 两条口径:
|
||||
// 三条口径:
|
||||
//
|
||||
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
|
||||
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
|
||||
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
|
||||
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
|
||||
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
|
||||
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
|
||||
type chromeOutput struct {
|
||||
@@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int {
|
||||
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
|
||||
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
|
||||
// 调用方据此判断「浏览器一个字都没说」。
|
||||
//
|
||||
// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go):
|
||||
// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串**
|
||||
// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话
|
||||
// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与
|
||||
// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进
|
||||
// 这份预算,因此截断与量级都照实可见。
|
||||
func (o *chromeOutput) preview() string {
|
||||
o.mu.Lock()
|
||||
raw := truncateRune(o.head)
|
||||
@@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string {
|
||||
break
|
||||
}
|
||||
}
|
||||
text := strings.Join(kept, " | ")
|
||||
// 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
|
||||
// 只有拼起来之后才看得见。
|
||||
text := chromeOutputPreview(strings.Join(kept, " | "))
|
||||
if truncated && text != "" {
|
||||
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
|
||||
return pageState{}, err
|
||||
}
|
||||
if text := strings.TrimSpace(res.ErrorText); text != "" {
|
||||
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text)
|
||||
// errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
|
||||
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text))
|
||||
}
|
||||
if err := s.waitReady(ctx); err != nil {
|
||||
return pageState{}, err
|
||||
@@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error {
|
||||
return err
|
||||
}
|
||||
if res.ExceptionDetails != nil {
|
||||
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text)
|
||||
// text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
|
||||
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
|
||||
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text))
|
||||
}
|
||||
if out == nil || len(res.Result.Value) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
|
||||
}
|
||||
|
||||
info := serverInfoFrom(session, c.hostFeatures())
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
|
||||
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil {
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
|
||||
out := make([]ToolInfo, 0, 8)
|
||||
for tool, err := range session.Tools(ctx, nil) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
out = append(out, toolInfoFrom(tool))
|
||||
}
|
||||
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
|
||||
}
|
||||
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
|
||||
}
|
||||
return ToolResult{
|
||||
Content: toContentBlocks(res.Content),
|
||||
@@ -165,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
|
||||
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
|
||||
func (c *Client) newSDKClient() *mcpsdk.Client {
|
||||
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
|
||||
Logger: c.log,
|
||||
// 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
|
||||
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
|
||||
Logger: redactingLogger(c.log),
|
||||
Capabilities: c.clientCapabilities(),
|
||||
CreateMessageHandler: c.wrapSampling(),
|
||||
ElicitationHandler: c.wrapElicitation(),
|
||||
@@ -188,12 +189,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
|
||||
}
|
||||
|
||||
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
|
||||
//
|
||||
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
|
||||
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
|
||||
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
|
||||
func wrapConnectErr(err error) error {
|
||||
safe := upstreamError(err)
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
|
||||
}
|
||||
return err
|
||||
return safe
|
||||
}
|
||||
|
||||
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
|
||||
//
|
||||
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
|
||||
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
|
||||
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
|
||||
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
|
||||
// **默认不带自由文本**,只留可分类的骨架。
|
||||
//
|
||||
// 留下的骨架(够定位,不够回显):
|
||||
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
|
||||
// 不是错误文本);
|
||||
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
|
||||
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
|
||||
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
|
||||
|
||||
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
|
||||
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
|
||||
//
|
||||
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
|
||||
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
|
||||
type upstreamFailure struct {
|
||||
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
|
||||
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
|
||||
code int64
|
||||
hasCode bool
|
||||
// length 是被扣下的自由文本的字节数。
|
||||
length int
|
||||
// cause 只用于分类,绝不参与 Error()。
|
||||
cause error
|
||||
}
|
||||
|
||||
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
|
||||
func (f *upstreamFailure) Error() string {
|
||||
if f.hasCode {
|
||||
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
|
||||
}
|
||||
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
|
||||
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
|
||||
// 少了它,取消会被上层当成普通工具失败继续跑。
|
||||
func (f *upstreamFailure) Unwrap() error { return f.cause }
|
||||
|
||||
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
|
||||
//
|
||||
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
|
||||
// JSON-RPC 信封),所以不能只断言最外层。
|
||||
func upstreamError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
failure := &upstreamFailure{cause: err, length: len(err.Error())}
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) {
|
||||
failure.code = wire.Code
|
||||
failure.hasCode = true
|
||||
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
|
||||
failure.length = len(wire.Message)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mcp
|
||||
|
||||
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
|
||||
//
|
||||
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
|
||||
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
|
||||
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
|
||||
//
|
||||
// 变异判据(每条都对应一个会红的实现):
|
||||
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
|
||||
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
|
||||
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
|
||||
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
|
||||
|
||||
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
|
||||
func sentinelServer() *mcpsdk.Server {
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
|
||||
srv.AddTool(&mcpsdk.Tool{
|
||||
Name: "boom",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
|
||||
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
|
||||
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := connectFakeClient(t, sentinelServer())
|
||||
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("server 返回协议错误时 CallTool 应当报错")
|
||||
}
|
||||
if strings.Contains(err.Error(), errSentinel) {
|
||||
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
|
||||
}
|
||||
for _, want := range []string{"boom", "fake", "code=-32603"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
|
||||
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
|
||||
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
|
||||
t.Parallel()
|
||||
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
|
||||
err := upstreamError(cause)
|
||||
|
||||
if strings.Contains(err.Error(), errSentinel) {
|
||||
t.Fatalf("自由文本进了错误串:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code=-32601") {
|
||||
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
|
||||
}
|
||||
// 分类面:WireError 的 Is 比的是码,链不能断。
|
||||
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
|
||||
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
|
||||
}
|
||||
var wire *sdkjsonrpc.Error
|
||||
if !errors.As(err, &wire) || wire.Code != -32601 {
|
||||
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
|
||||
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
|
||||
// 收敛文本时绝不能把这条链切断。
|
||||
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "context canceled") {
|
||||
t.Errorf("取消的原文也被带进了错误串:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package mcp
|
||||
|
||||
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
|
||||
//
|
||||
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
|
||||
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
|
||||
//
|
||||
// logger.Error("keepalive ping failed; closing session", "error", err, …)
|
||||
//
|
||||
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
|
||||
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
|
||||
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
|
||||
// 了对端回显的整句话。
|
||||
//
|
||||
// 收敛口径(与 client.go 同一刻画,不做第二套):
|
||||
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
|
||||
// `upstreamError` 渲染的 code + message_bytes;
|
||||
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
|
||||
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
|
||||
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
|
||||
//
|
||||
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
|
||||
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
|
||||
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
|
||||
//
|
||||
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
|
||||
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
|
||||
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
|
||||
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
|
||||
const maxSDKLogRunes = 300
|
||||
|
||||
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
|
||||
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
|
||||
func redactingLogger(l *slog.Logger) *slog.Logger {
|
||||
return slog.New(&redactingHandler{inner: l.Handler()})
|
||||
}
|
||||
|
||||
// redactingHandler 是 redactingLogger 的 handler 形态。
|
||||
type redactingHandler struct {
|
||||
inner slog.Handler
|
||||
}
|
||||
|
||||
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
|
||||
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.inner.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
|
||||
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
out.AddAttrs(scrubSDKAttr(a))
|
||||
return true
|
||||
})
|
||||
return h.inner.Handle(ctx, out)
|
||||
}
|
||||
|
||||
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
|
||||
// 记录会绕过本文件。
|
||||
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
scrubbed := make([]slog.Attr, len(attrs))
|
||||
for i, a := range attrs {
|
||||
scrubbed[i] = scrubSDKAttr(a)
|
||||
}
|
||||
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
|
||||
}
|
||||
|
||||
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
|
||||
func (h *redactingHandler) WithGroup(name string) slog.Handler {
|
||||
return &redactingHandler{inner: h.inner.WithGroup(name)}
|
||||
}
|
||||
|
||||
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
|
||||
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
|
||||
func scrubSDKText(s string) string {
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
|
||||
}
|
||||
|
||||
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
|
||||
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
|
||||
func scrubSDKAttr(a slog.Attr) slog.Attr {
|
||||
switch a.Value.Kind() {
|
||||
case slog.KindString:
|
||||
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
|
||||
case slog.KindAny:
|
||||
if err, ok := a.Value.Any().(error); ok && err != nil {
|
||||
a.Value = slog.StringValue(scrubSDKError(err))
|
||||
}
|
||||
case slog.KindGroup:
|
||||
group := a.Value.Group()
|
||||
scrubbed := make([]slog.Attr, len(group))
|
||||
for i, item := range group {
|
||||
scrubbed[i] = scrubSDKAttr(item)
|
||||
}
|
||||
a.Value = slog.GroupValue(scrubbed...)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// scrubSDKError 区分两类错误来源(口径见文件头):
|
||||
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
|
||||
// message_bytes),一个字节的自由文本都不留;
|
||||
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
|
||||
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
|
||||
func scrubSDKError(err error) string {
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) {
|
||||
return upstreamError(err).Error()
|
||||
}
|
||||
return scrubSDKText(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package mcp
|
||||
|
||||
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
|
||||
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
|
||||
//
|
||||
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
|
||||
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
|
||||
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
|
||||
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
|
||||
//
|
||||
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
|
||||
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
|
||||
//
|
||||
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
|
||||
// 说的那句话」被对端回显)。
|
||||
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
|
||||
|
||||
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
|
||||
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
|
||||
// keepalive 日志」这条路径。
|
||||
//
|
||||
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
|
||||
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
|
||||
type pingErrorTransport struct {
|
||||
inner mcpsdk.Transport
|
||||
}
|
||||
|
||||
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
|
||||
conn, err := t.inner.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newPingErrorConn(conn), nil
|
||||
}
|
||||
|
||||
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
|
||||
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
|
||||
type pingErrorConn struct {
|
||||
inner mcpsdk.Connection
|
||||
msgs chan sdkjsonrpc.Message
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
|
||||
c := &pingErrorConn{
|
||||
inner: inner,
|
||||
msgs: make(chan sdkjsonrpc.Message, 16),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go c.pump()
|
||||
return c
|
||||
}
|
||||
|
||||
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
|
||||
func (c *pingErrorConn) pump() {
|
||||
defer close(c.msgs)
|
||||
for {
|
||||
msg, err := c.inner.Read(context.Background())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.msgs <- msg:
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
|
||||
req, ok := msg.(*sdkjsonrpc.Request)
|
||||
if !ok || req.Method != "ping" {
|
||||
return c.inner.Write(ctx, msg)
|
||||
}
|
||||
select {
|
||||
case c.msgs <- &sdkjsonrpc.Response{
|
||||
ID: req.ID,
|
||||
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
|
||||
}:
|
||||
return nil
|
||||
case <-c.done:
|
||||
return context.Canceled
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
return msg, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pingErrorConn) Close() error {
|
||||
c.once.Do(func() { close(c.done) })
|
||||
return c.inner.Close()
|
||||
}
|
||||
|
||||
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
|
||||
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
|
||||
|
||||
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
|
||||
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
|
||||
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
|
||||
c := New(MCPConfig{Name: "fake"},
|
||||
WithTransport(startFakeServer(t, sentinelServer())),
|
||||
WithLogger(log))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
|
||||
t.Fatal("server 返回协议错误时 CallTool 应当报错")
|
||||
}
|
||||
if got := buf.String(); strings.Contains(got, errSentinel) {
|
||||
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
|
||||
}
|
||||
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
|
||||
}
|
||||
|
||||
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
|
||||
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
|
||||
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
|
||||
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
|
||||
c := New(MCPConfig{Name: "fake"},
|
||||
WithTransport(tr),
|
||||
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
|
||||
WithProtocolVersion("2025-11-25"),
|
||||
WithKeepAlive(20*time.Millisecond, 1),
|
||||
WithLogger(log))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
|
||||
return strings.Contains(buf.String(), "keepalive ping failed")
|
||||
})
|
||||
logged := buf.String()
|
||||
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
|
||||
if !strings.Contains(logged, "keepalive ping failed") {
|
||||
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
|
||||
}
|
||||
if strings.Contains(logged, sdkLogSentinel) {
|
||||
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
|
||||
}
|
||||
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package mcp
|
||||
|
||||
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
|
||||
// 用真 server 证明这条日志面确实走过收敛。
|
||||
//
|
||||
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
|
||||
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
)
|
||||
|
||||
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
|
||||
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
|
||||
wire := fmt.Errorf("calling %q: %w", "ping",
|
||||
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
|
||||
got := scrubSDKError(wire)
|
||||
if strings.Contains(got, "SENTINEL-MCPLOG") {
|
||||
t.Fatalf("线上错误的自由文本进了日志:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "code=-32603") {
|
||||
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
|
||||
}
|
||||
|
||||
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
|
||||
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
|
||||
t.Fatalf("本地错误的原因被吞掉了:%q", got)
|
||||
}
|
||||
|
||||
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
|
||||
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
|
||||
got = scrubSDKError(leaky)
|
||||
if strings.Contains(got, "sk-live") {
|
||||
t.Fatalf("本地错误里的凭据没被打码:%q", got)
|
||||
}
|
||||
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
|
||||
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
|
||||
// 状态,不该被字符串化或截断。
|
||||
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
|
||||
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package restrict
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
|
||||
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
|
||||
}
|
||||
|
||||
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
||||
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
||||
// 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
|
||||
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
|
||||
const sensitiveSummaryRunes = 80
|
||||
|
||||
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
||||
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
|
||||
|
||||
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
||||
// 多字节字符);目标为空时只返回动作。
|
||||
//
|
||||
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
|
||||
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
|
||||
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
|
||||
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
|
||||
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
|
||||
func summarize(action, target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
target = trace.RedactText(strings.TrimSpace(target))
|
||||
if target == "" {
|
||||
return action
|
||||
}
|
||||
|
||||
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
|
||||
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
|
||||
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
|
||||
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-9f8e7d6c5b4a3210"
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
start string // 摘要应保留的开头(打码不能把摘要打没)
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
|
||||
start: "terminal ",
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
|
||||
start: "http POST ",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||
if !sensitive {
|
||||
t.Fatalf("%s 应判定为敏感", tc.name)
|
||||
}
|
||||
if strings.Contains(detail, secret) {
|
||||
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
|
||||
}
|
||||
if !strings.Contains(detail, "[redacted]") {
|
||||
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
|
||||
}
|
||||
if !strings.HasPrefix(detail, tc.start) {
|
||||
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
|
||||
@@ -13,9 +15,13 @@ import (
|
||||
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(字节)。它只含模型
|
||||
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
|
||||
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
|
||||
// 消息内容带进日志。
|
||||
//
|
||||
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
|
||||
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
|
||||
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
|
||||
const reviewExcerptLen = 40
|
||||
|
||||
// ParseReviewIndices decodes the keep-index array a review model returned, rising
|
||||
@@ -83,12 +89,12 @@ func unwrapCodeFence(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// reviewExcerpt truncates text for an error message so the next parse failure is
|
||||
// diagnosable without logging the whole completion.
|
||||
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
|
||||
//
|
||||
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
|
||||
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
|
||||
// 半个,错误串因此变成非法 UTF-8;
|
||||
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
|
||||
func reviewExcerpt(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= reviewExcerptLen {
|
||||
return s
|
||||
}
|
||||
return s[:reviewExcerptLen] + "…"
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
|
||||
@@ -126,3 +127,45 @@ func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
|
||||
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
|
||||
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
|
||||
//
|
||||
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
|
||||
// (utf8.ValidString 为 false)。
|
||||
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
|
||||
}
|
||||
body := strings.TrimSuffix(got, "…")
|
||||
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
|
||||
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("超限时应当带省略号:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
|
||||
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
|
||||
//
|
||||
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
|
||||
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "sk-abcdefghijklmnop"
|
||||
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("凭据形状没被打码:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[redacted]") {
|
||||
t.Errorf("打码标记缺失:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "[0, 1]") {
|
||||
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,16 @@
|
||||
// future platforms; existing adapters (Telegram, OneBot) that already carry
|
||||
// their own transport are left untouched.
|
||||
//
|
||||
// It depends only on the standard library and internal/logger. The auth token is
|
||||
// never logged or surfaced in errors. The Authorization scheme is configurable
|
||||
// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
|
||||
// It depends only on the standard library, internal/logger and internal/runtime/trace.
|
||||
// The auth token is never logged or surfaced in errors. The Authorization scheme is
|
||||
// configurable (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
|
||||
//
|
||||
// The envelope's message is an upstream free-text channel: upstreams echo the rejected
|
||||
// request body into it, and that body holds what she said. It is therefore **not**
|
||||
// carried in errors by default (see WithEnvelopeMessage); code and HTTP status are. The
|
||||
// same rule — code/status stay, free text goes — applies to every channel adapter's own
|
||||
// envelope handling (Telegram description, OneBot msg, iLink CDN x-error-message); see
|
||||
// upstream_text.go.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
@@ -40,6 +47,9 @@ type Client struct {
|
||||
token string
|
||||
authScheme string
|
||||
log *slog.Logger
|
||||
// keepEnvelopeMessage 是**默认关闭**的开关:信封 message 只有显式 opt-in
|
||||
// (WithEnvelopeMessage)才会进错误文本。零值 = 安全,见该函数的注释。
|
||||
keepEnvelopeMessage bool
|
||||
}
|
||||
|
||||
// Option customizes a Client at construction time.
|
||||
@@ -81,6 +91,23 @@ func WithLogger(l *slog.Logger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnvelopeMessage 显式要求把信封的 message 保留在错误文本里。**默认不带**。
|
||||
//
|
||||
// 🔴 默认不带是刻意的,理由与 trace.UpstreamError 的 message 同源:上游会把被拒的输入
|
||||
// 回显在 message 里(KOOK 实测 `invalid content: <她原话>`),于是那句话会随错误进
|
||||
// 调用方的日志。排障真正用得到的分类信息是 `code` 与 `status`,两者**恒在**;message
|
||||
// 只是人话提示。宁可少一条提示,也不让她的原话进日志。
|
||||
//
|
||||
// 只有在你**确认该上游不回显用户内容**时才打开。打开后 message 仍会压平空白、清洗
|
||||
// 凭据形状并截到 maxUpstreamTextRunes(40 rune)—— 上限不像 trace 的 128,是这层没有
|
||||
// 「一屏摘要」的诉求,短到装不下一句正文。
|
||||
//
|
||||
// 函数式选项的形状让最常用的 `New(...)` 天然就是最安全的那个:想放宽必须在这里显式
|
||||
// 写出来,代码评审时一眼可见(与 trace.WithUpstreamMessage 同一个范式)。
|
||||
func WithEnvelopeMessage() Option {
|
||||
return func(c *Client) { c.keepEnvelopeMessage = true }
|
||||
}
|
||||
|
||||
// New builds a Client. With no base URL the caller must pass absolute URLs (via
|
||||
// the Do/Get helpers' path being an absolute URL).
|
||||
func New(opts ...Option) *Client {
|
||||
@@ -133,8 +160,8 @@ func (c *Client) resolve(path string) string {
|
||||
//
|
||||
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
|
||||
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
|
||||
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的
|
||||
// code/message(清洗并截断),其余字节一概不留。
|
||||
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的 code
|
||||
// (message 默认不带,见 WithEnvelopeMessage),其余字节一概不留。
|
||||
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
|
||||
host := trace.HostOf(target)
|
||||
var buf bytes.Buffer
|
||||
@@ -145,7 +172,7 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, transportCause(err))
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, host, trace.URLCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -159,16 +186,16 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, transportCause(err))
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, host, trace.URLCause(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, transportCause(err))
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, trace.URLCause(err))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, apiFailure(method, host, resp, raw)
|
||||
return nil, c.apiFailure(method, host, resp, raw)
|
||||
}
|
||||
|
||||
var envelope APIEnvelope
|
||||
@@ -176,49 +203,49 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
|
||||
return raw, nil
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
|
||||
method, host, envelope.Code, envelopeMessage(envelope.Message), ErrAPI)
|
||||
if detail := c.envelopeDetail(envelope.Message); detail != "" {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
|
||||
method, host, envelope.Code, detail, ErrAPI)
|
||||
}
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %w", method, host, envelope.Code, ErrAPI)
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
|
||||
// 外加(当体确实是 `{code,message}` 信封时)清洗并截断过的 code/message。其余字节只
|
||||
// 经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份报文」这类相关性判断。
|
||||
func apiFailure(method, host string, resp *http.Response, raw []byte) error {
|
||||
// 外加(当体确实是 `{code,message}` 信封时)code —— message 同样只在显式 opt-in 时
|
||||
// 才出现。其余字节只经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份
|
||||
// 报文」这类相关性判断。
|
||||
func (c *Client) apiFailure(method, host string, resp *http.Response, raw []byte) error {
|
||||
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
|
||||
var envelope APIEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err == nil {
|
||||
message := envelopeMessage(envelope.Message)
|
||||
detail := c.envelopeDetail(envelope.Message)
|
||||
switch {
|
||||
case envelope.Code != 0 && message != "":
|
||||
case envelope.Code != 0 && detail != "":
|
||||
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
|
||||
method, host, envelope.Code, message, upstream, ErrAPI)
|
||||
method, host, envelope.Code, detail, upstream, ErrAPI)
|
||||
case envelope.Code != 0:
|
||||
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
|
||||
case message != "":
|
||||
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, message, upstream, ErrAPI)
|
||||
case detail != "":
|
||||
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, detail, upstream, ErrAPI)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
|
||||
}
|
||||
|
||||
// envelopeMessage 是信封 message 进入错误前的唯一处理:压平空白,再交给
|
||||
// trace.RedactText —— 它既清洗凭据形状,也把长度压到 80 rune(超出的带省略号)。
|
||||
// 上游常把被拒的输入回显在 message 里,因此这一层不是可选的。
|
||||
func envelopeMessage(s string) string {
|
||||
return trace.RedactText(strings.Join(strings.Fields(s), " "))
|
||||
}
|
||||
|
||||
// transportCause 剥掉 net/http 的 *url.Error 外壳:它会把**完整请求地址**拼进错误串
|
||||
// (query 里的凭据、user:pass@ 都会跟着走)。剥壳保留内层哨兵链
|
||||
// (context.DeadlineExceeded 等仍可 errors.Is),因此只丢地址、不丢原因。
|
||||
// 与 internal/channel/mediafetch 的 sanitizeErr 同一口径 —— 那份未导出,且服务于入站
|
||||
// 取回路径,本包不能反向依赖它。
|
||||
func transportCause(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||||
return uerr.Err
|
||||
// envelopeDetail 返回**允许进错误文本**的信封 message:默认空串(不带)。
|
||||
//
|
||||
// 为什么默认不带(这条取舍是实测得出的):上游会把被拒的请求正文回显在 message 里,
|
||||
// 而那条正文就是她的话。M49 复审的探针在隔离副本里拿到的实际文本是
|
||||
// `... code 1: invalid content: 她刚才说的那句话: ...: status=400` —— 她的原话既进了
|
||||
// 错误也进了调用方的日志。排障需要的是 code 与 status(两者恒在),不是那句话。
|
||||
//
|
||||
// 只有 WithEnvelopeMessage() 显式打开后,这里才返回 upstreamText(压平空白 + 凭据打码
|
||||
// + 截到 40 rune)。
|
||||
func (c *Client) envelopeDetail(message string) string {
|
||||
if !c.keepEnvelopeMessage {
|
||||
return ""
|
||||
}
|
||||
return err
|
||||
return upstreamText(message)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件钉住本客户端的两条出镜边界(M41 #10/#11 那一族):
|
||||
// 本文件钉住本客户端的出镜边界(M41 #10/#11 那一族,M50 补信封 message):
|
||||
//
|
||||
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
|
||||
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
|
||||
// 什么。现在只留状态码、来源主机,以及信封的 code/message(已清洗、已截断)。
|
||||
// 什么。现在只留状态码、来源主机,以及信封的 code(message 默认不带)。
|
||||
// - **信封 message 默认不带**:上游会用它在被拒时回显请求正文(也就是她的话)。
|
||||
// 要带必须显式 WithEnvelopeMessage(),那时仍过 upstreamText(凭据打码 + 40 rune)。
|
||||
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
|
||||
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
|
||||
//
|
||||
@@ -12,13 +14,17 @@ package httpclient
|
||||
// 打进日志的那段字符串。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
|
||||
@@ -32,8 +38,8 @@ func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
|
||||
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
|
||||
//
|
||||
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留
|
||||
// code/message,非信封体则一个字节都不留。
|
||||
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留 code(message
|
||||
// 默认不带),非信封体则一个字节都不留。
|
||||
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "SENTINEL-4XX-BODY"
|
||||
@@ -67,44 +73,117 @@ func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_StatusErrorKeepsEnvelopeCode 信封体仍要给出 code/message:这是本客户端
|
||||
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。
|
||||
// TestDoJSON_StatusErrorKeepsEnvelopeCode 非 2xx 的信封体仍要给出 code:这是本客户端
|
||||
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。而 message 是上游自由文本面,
|
||||
// **默认不带**(M50 口径)—— 上游会用它在被拒时回显她的话。
|
||||
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "invalid content: 她刚才说的那句话"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"invalid token","data":null}`))
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"` + sentinel + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("401 却报成功")
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "invalid token") {
|
||||
t.Fatalf("信封的 code/message 丢了:%v", err)
|
||||
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "status=400") {
|
||||
t.Fatalf("信封的 code / 上游 status 丢了:%v", err)
|
||||
}
|
||||
// 哨兵出现在 message 里 ⇒ 错误与日志都不得带它(变异:把 envelopeDetail 改回
|
||||
// 无条件 upstreamText(message) ⇒ 本断言立刻红)。
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("send failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("信封 message(上游回显的她的原话)进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
// 哨兵链不变:分类靠 errors.Is,不靠文本。
|
||||
if !errors.Is(err, ErrAPI) || !errors.Is(err, trace.ErrUpstream) {
|
||||
t.Fatalf("err = %v, want both ErrAPI and trace.ErrUpstream", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_NonZeroCodeRedactsMessageSecrets 信封 message 是上游自由文本面:里面的
|
||||
// 凭据形状必须被清洗掉,长度必须被截断。改回原样透传就变红。
|
||||
func TestDoJSON_NonZeroCodeRedactsMessageSecrets(t *testing.T) {
|
||||
// TestDoJSON_NonZeroCodeDropsEnvelopeMessage 是 2xx + `code != 0` 这**另一个分支**:
|
||||
// 它此前把 message 直接拼进错误(M49 探针复现的原文里 `code 1: invalid content: …`
|
||||
// 走的就是跨过 apiFailure 的这一支)。code 必须留下,message 必须不带。
|
||||
func TestDoJSON_NonZeroCodeDropsEnvelopeMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
const sentinel = "invalid content: 她刚才说的那句话"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 200 + 非零 code:KOOK 一类平台的「业务失败也 200」形态。
|
||||
_, _ = w.Write([]byte(`{"code":1,"message":"` + sentinel + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"content": "她刚才说的那句话"})
|
||||
if err == nil {
|
||||
t.Fatal("非零 code 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), "她刚才说的那句话") {
|
||||
t.Fatalf("200 + code!=0 分支仍把 message 拼进错误:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "code 1") {
|
||||
t.Fatalf("2xx 分支的 code 丢了(那一条没别的分类信息):%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets opt-in(WithEnvelopeMessage)是
|
||||
// 唯一能带 message 的通道:带上的那一刻仍必须清洗凭据形状。
|
||||
func TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "big-secret-value-123456"
|
||||
body := `{"code":40101,"message":"invalid access_token=` + secret + `","data":null}`
|
||||
body := `{"code":40101,"message":"access_token=` + secret + `","data":null}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()), WithToken(secret))
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()), WithToken(secret), WithEnvelopeMessage())
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("非零 code 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("信封 message 里的凭据原样进了错误:%v", err)
|
||||
t.Fatalf("opt-in 的信封 message 没有清洗凭据形状:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[redacted]") {
|
||||
t.Fatalf("opt-in 生效后错误里没有 message(无法证明这条通道还活着):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_EnvelopeMessageOptInIsBounded 钉住 opt-in 下 message 的长度口径:40 rune
|
||||
// (maxUpstreamTextRunes)+ 省略号。这条让「到底哪一层在截、截到多少」变成可断言的事实 ——
|
||||
// 上界刻意比 trace.RedactText 的 80 更短(错误里那句话是提示,不是摘要)。
|
||||
func TestDoJSON_EnvelopeMessageOptInIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("a", 300)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()), WithEnvelopeMessage())
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("401 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes)+"…") {
|
||||
t.Fatalf("信封 message 没有按 %d rune 截断:%v", maxUpstreamTextRunes, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes+1)) {
|
||||
t.Fatalf("信封 message 超过了 %d rune 上限:%v", maxUpstreamTextRunes, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,42 +210,25 @@ func TestGetJSON_TransportErrorOmitsTargetCredentials(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestGetJSON_UserinfoNeverEntersError 绝对地址带 user:pass@ 时同样不得出镜。
|
||||
//
|
||||
// 断言用户名而不只是密码:net/http 自己在 *url.Error 里就把密码打成 xxxxx,因此
|
||||
// 「密码不出现」在剥壳与不剥壳两种实现下都成立(这样的用例是空跑);用户名才是
|
||||
// 剥壳与不剥壳的分水岭。
|
||||
func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(
|
||||
WithHTTPClient(&http.Client{Transport: failingTransport{}}),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
_, err := c.GetJSON(context.Background(), "https://user:pass@api.example.com/x", nil)
|
||||
_, err := c.GetJSON(context.Background(), "https://leakuser:leakpass@api.example.com/x", nil)
|
||||
if err == nil {
|
||||
t.Fatal("传输层失败却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), "user:pass") {
|
||||
t.Fatalf("user:pass@ 进了错误:%v", err)
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "leakuser") || strings.Contains(msg, "leakpass") {
|
||||
t.Fatalf("user:pass@ 进了错误:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSON_EnvelopeMessageIsBoundedByRedactText 钉住长度口径:信封 message 只有一道
|
||||
// 清洗(trace.RedactText,80 rune + 省略号)。本片曾按 200 rune 自己再截一刀,那层
|
||||
// 永远命中不了 —— 这条用例让「到底哪一层在截」变成可断言的事实。
|
||||
func TestDoJSON_EnvelopeMessageIsBoundedByRedactText(t *testing.T) {
|
||||
t.Parallel()
|
||||
long := strings.Repeat("a", 300)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if err == nil {
|
||||
t.Fatal("401 却报成功")
|
||||
}
|
||||
if !strings.Contains(err.Error(), strings.Repeat("a", 80)+"…") {
|
||||
t.Fatalf("信封 message 没有按 80 rune 截断:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), strings.Repeat("a", 81)) {
|
||||
t.Fatalf("信封 message 超过了 80 rune 上限:%v", err)
|
||||
if !strings.Contains(msg, "api.example.com") {
|
||||
t.Fatalf("剥掉地址时把来源一起丢了:%v", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件是**渠道侧上游自由文本的唯一清洗入口**(M50)。它管的是同一件事在四个出口上
|
||||
// 的形态:KOOK 一类 `{code,message}` 信封的 `message`、Telegram Bot API 的
|
||||
// `description`、OneBot v11 信封的 `msg` / `wording`、iLink CDN 的 `x-error-message`
|
||||
// 头。四者都是上游给的**人话**,都可能把被拒的请求内容回显出来 —— 而请求内容里就有
|
||||
// 她的原话。
|
||||
//
|
||||
// 🔴 契约(改本文件前先读):
|
||||
// - 上层口径是 **默认不带**:这些字段默认不进 error、不进日志(见 client.go 的
|
||||
// WithEnvelopeMessage 与各渠道调用点的注释)。本文件的函数**只在调用方已经显式
|
||||
// 选择保留**时才被调用 —— 它是「保留时的上限」,不是「可以带」的许可。
|
||||
// - 清洗只有一份:`trace.RedactTextLimited`(凭据形状打码 + rune 截断)。本包**不**
|
||||
// 另写一套正则或截断,另写一套必然漂移,而漂移的方向总是「其中一条出口开始原样
|
||||
// 吐凭据」。
|
||||
// - 压平空白也是必需的一步:一条诊断只许占一行,否则上游可以用换行伪造日志行
|
||||
// (log injection)。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxUpstreamTextRunes 是上游自由文本允许保留的 rune 上限:**40**。
|
||||
//
|
||||
// 为什么比 `trace.RedactText` 的 80 更短:那一档服务的是**摘要素材**(出图、工具结果
|
||||
// 摘要),读者只看这一条;本文件的产物进的是**错误链与日志**,同一条信息还会被调用方
|
||||
// 再落一次,长度预算没有理由按摘要给。而 40 rune 已足够装下渠道侧真正有用的那类说明
|
||||
// (`invalid token`、`参数错误`、`缺少 code`、`FLOOD_WAIT_30`),又短到装不下一句她
|
||||
// 说的话 —— 上游回显 `invalid content: <正文>` 时,前半句就吃掉大半预算。
|
||||
const maxUpstreamTextRunes = 40
|
||||
|
||||
// upstreamText 把一段上游自由文本收敛成可以出镜的短诊断:压平空白,再交给
|
||||
// `trace.RedactTextLimited` 打码凭据形状并截到 maxUpstreamTextRunes(超出的带省略号)。
|
||||
//
|
||||
// 空串进 ⇒ 空串出;调用方据此判断「这一段有没有东西可显示」。
|
||||
//
|
||||
// 🔴 它**不**决定该不该带(默认不带)。把它的结果无条件拼进 error 仍然是把她的原话
|
||||
// 送上日志 —— 调用点必须自己持有那个显式的开关(本包只有 WithEnvelopeMessage 一处)。
|
||||
// 渠道插件当前**不带**任何自由文本,因此没有第二个调用点;将来若真要开,也要回到
|
||||
// 这一条(`trace` 的唯一清洗 + 40 rune),不要在插件里另写一套截断。
|
||||
func upstreamText(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxUpstreamTextRunes)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package httpclient
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时,
|
||||
// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**,
|
||||
// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认
|
||||
// 不安全」,所以这一条故意不去调用点补。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type
|
||||
// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。
|
||||
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
|
||||
if err == nil {
|
||||
t.Fatal("502 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
// 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。
|
||||
for _, want := range []string{"status=502", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -179,7 +180,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, sanitizeErr(err))
|
||||
return nil, fmt.Errorf("mediafetch: 取回 %s 失败: %w", origin, trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -189,7 +190,7 @@ func Fetch(ctx context.Context, req Request) ([]byte, error) {
|
||||
// 边读边限:多读一个字节用来判定「超限」,因此绝不会把一份超大文件读满内存。
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, sanitizeErr(err))
|
||||
return nil, fmt.Errorf("mediafetch: 读取 %s 失败: %w", origin, trace.URLCause(err))
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, fmt.Errorf("%w(上限 %d 字节,来源 %s)", ErrTooLarge, maxBytes, origin)
|
||||
@@ -239,18 +240,6 @@ func applyHeader(dst, src http.Header) {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeErr 剥掉 *url.Error 的外壳:它会把**完整 URL**(含查询串里的签名凭据)
|
||||
// 拼进错误串。剥壳同时保留内层哨兵链(`context.DeadlineExceeded` 等仍可用
|
||||
// errors.Is 判定)。内层已是最终错误时**原样透传**(这里只做剥壳,不新增包装 ——
|
||||
// 再包一层只会把同一个原因说两遍)。
|
||||
func sanitizeErr(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||||
return uerr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// originOfURL 把一个已解析的 URL 归一成来源字符串。
|
||||
func originOfURL(u *url.URL) (string, error) {
|
||||
if u == nil {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// voice_tts_launch.go 是 **emotionTTS 自启**(切片 M47)的可变源:
|
||||
// `$DATA_DIR/config/voice-tts-launch.json`(与 `voice-tts.json` / `backthink.json`
|
||||
// 同一家族口径,design/13 §2.1:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/` +
|
||||
// 代码默认值;`.env` 已冻结,**零新 env 键**)。
|
||||
//
|
||||
// 为什么需要它:主人 2026-10-24 逐字「tts启动脚本在这
|
||||
// `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`,以后启动后端可以顺便检测
|
||||
// 一下如果TTS没起就顺带启一下」。这是**一条运维便利**,不是后端的一部分:
|
||||
//
|
||||
// - **默认关**(nil / false 都等于不启用)。主人早前说过「开机自启先不急 后面做」
|
||||
// ⇒ 未显式打开时,后端连一次探活都不发,更不会拉起任何进程。
|
||||
// - 拉起动作整段在后台 goroutine 里(见 `internal/runtime/voice/ttslaunch`),
|
||||
// **不占**后端启动时长;失败只 WARN,绝不阻塞或改坏后端启动路径。
|
||||
//
|
||||
// 与 `voice-tts.json` 分开而不是塞进同一份文件:那一份管「合成怎么调」(地址/
|
||||
// 角色映射/超时),这一份管「服务没起时要不要顺手拉一下」。两件事的失败语义、
|
||||
// 默认值、被谁读都不同(合成面读一次决定注入与否;本文件读一次决定是否起进程),
|
||||
// 合在一份会让"只想开合成"的人被迫接触进程拉起这个更重的开关。
|
||||
//
|
||||
// 边界(与 voicetts.go 同源的手续):
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读取**:不认识 `ttslaunch.Config`,也不裁决"缺省填
|
||||
// 什么值"(那是 `ttslaunch.Config.Normalized` 的事)。
|
||||
// 2. **没有"面板编辑"的写口**:改内容仍要人手改文件 + 重启(启动期读一次)。
|
||||
// 3. **零新 env 键**,也不进面板的配置事实卡。
|
||||
//
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
|
||||
// = 返回分类错误,调用方记 WARN 并**回落"不启用"**(一份读不懂的配置绝不能变成
|
||||
// 「随手拉起进程」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// voiceTTSLaunchFileName 是文件名(家族命名 = 模块名 + .json)。
|
||||
voiceTTSLaunchFileName = "voice-tts-launch.json"
|
||||
// voiceTTSLaunchConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。
|
||||
voiceTTSLaunchConfigSubdir = "config"
|
||||
)
|
||||
|
||||
// VoiceTTSLaunchConfigRelPath 是落点相对 `$DATA_DIR` 的路径
|
||||
// (`config/voice-tts-launch.json`)。导出它是为了让日志/文档能**如实**告诉主人
|
||||
// 改哪个文件。
|
||||
const VoiceTTSLaunchConfigRelPath = voiceTTSLaunchConfigSubdir + "/" + voiceTTSLaunchFileName
|
||||
|
||||
// ErrInvalidVoiceTTSLaunch 是 voice-tts-launch.json 结构不合法时的分类错误
|
||||
// (errors.Is 可判类)。
|
||||
var ErrInvalidVoiceTTSLaunch = errors.New("runtimeconfig: invalid voice tts launch config")
|
||||
|
||||
// VoiceTTSLaunchTuning 是 `voice-tts-launch.json` 的形状:启动期「探一下、没起
|
||||
// 就顺手拉起」的几个旋钮。
|
||||
//
|
||||
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
|
||||
type VoiceTTSLaunchTuning struct {
|
||||
// Enabled 控制后端启动时是否顺带探活/拉起 emotionTTS。nil = 没配
|
||||
// (**不启用**);true = 启用;false = 显式关掉(与"没配"同效,写它只是为了
|
||||
// 自解释)。
|
||||
//
|
||||
// 用**指针**是刻意的:`bool` 的零值 false 与"键没写"不可区分,而这一级的默认
|
||||
// 语义是**关**(它会**起进程**,是比"调个超时"重得多的副作用,不能靠"猜"打开)。
|
||||
Enabled *bool `json:"enabled"`
|
||||
// ScriptPath 是拉起用的启动脚本(主人的 `一键启动.bat`)。
|
||||
// 空 = 代码默认(`ttslaunch.DefaultScriptPath`)。
|
||||
//
|
||||
// 脚本不存在只 WARN(这台机器上没装 emotionTTS 是正常形态),不是错误。
|
||||
ScriptPath string `json:"scriptPath,omitempty"`
|
||||
// ProbeURL 是探活地址。空 = 代码默认 `http://127.0.0.1:9880`。
|
||||
//
|
||||
// 它回答的问题是「这个端口上已经有人在应答吗」——**任何** HTTP 回应都算在跑
|
||||
// (见 ttslaunch 的探活口径),因此填一个别的服务的地址只会让它永远"已在跑"。
|
||||
ProbeURL string `json:"probeUrl,omitempty"`
|
||||
// ProbeTimeoutMs 是单次探活超时(**毫秒**)。零/负 = 代码默认。
|
||||
//
|
||||
// 它决定的是「等多久才认定没起」:这是启动**之后**的后台动作,但过大的值只会让
|
||||
// "没装 TTS"的机器白等,过小则可能把"正在初始化"误判成"没起"。
|
||||
ProbeTimeoutMs int `json:"probeTimeoutMs,omitempty"`
|
||||
// CooldownSec 是**一次拉起尝试之后的冷却**(**秒**)。零/负 = 代码默认。
|
||||
//
|
||||
// 它的用途是防「后端重启风暴」:主人连点重启时,每次都去拉一个已在启动中的
|
||||
// TTS 只会堆出多个抢同一个端口的进程。
|
||||
CooldownSec int `json:"cooldownSec,omitempty"`
|
||||
}
|
||||
|
||||
// EnabledOn 报告自启是否被显式打开(nil = 没配 ⇒ 不启用)。
|
||||
func (t VoiceTTSLaunchTuning) EnabledOn() bool { return t.Enabled != nil && *t.Enabled }
|
||||
|
||||
// DefaultVoiceTTSLaunchPath 返回落点:`$DATA_DIR/config/voice-tts-launch.json`。
|
||||
func DefaultVoiceTTSLaunchPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, voiceTTSLaunchConfigSubdir, voiceTTSLaunchFileName)
|
||||
}
|
||||
|
||||
// LoadVoiceTTSLaunchTuning 读取 voice-tts-launch.json(**只读**)。
|
||||
//
|
||||
// 文件缺失或全空白 ⇒ 零值 + nil:调用方据此得出"没配过(不启用)",**不是**错误
|
||||
// —— 绝大多数机器不装 emotionTTS,不该每次启动都刷 WARN。
|
||||
//
|
||||
// 存在但解不开 ⇒ 返回包装了 ErrInvalidVoiceTTSLaunch 的错误。调用方应记一条带
|
||||
// source 的 WARN 并**回落"不启用"**;**绝不得覆盖**那个文件(人工救得回来,
|
||||
// 程序盖掉就丢了人写的意图)。
|
||||
func LoadVoiceTTSLaunchTuning(path string) (VoiceTTSLaunchTuning, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return VoiceTTSLaunchTuning{}, nil
|
||||
}
|
||||
return VoiceTTSLaunchTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return VoiceTTSLaunchTuning{}, nil
|
||||
}
|
||||
var t VoiceTTSLaunchTuning
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return VoiceTTSLaunchTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidVoiceTTSLaunch, path, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadVoiceTTSLaunchTuning 锁定 voice-tts-launch.json 的四条读取口径:
|
||||
// 缺失 = 没配过(不是错误、**不启用**)、全空白 = 没配过、合法 = 逐键读出、
|
||||
// 坏文件 = 可分类错误(调用方据此回落"不启用")。
|
||||
func TestLoadVoiceTTSLaunchTuning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "voice-tts-launch.json")
|
||||
|
||||
// ① 缺失 ⇒ 零值 + nil(默认关:这把开关会起进程,不能靠"猜"打开)
|
||||
if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
||||
}
|
||||
|
||||
// ② 全空白 ⇒ 同样当作没配过
|
||||
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if tuning, err := LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
||||
}
|
||||
|
||||
// ③ 合法 ⇒ 逐键读出(脚本路径含中文,正是主人那台机器的形状)
|
||||
body := `{"enabled":true,"scriptPath":"D:\\Project\\Service\\emotionTTS_v5.1.0\\一键启动.bat",
|
||||
"probeUrl":"http://127.0.0.1:9880","probeTimeoutMs":250,"cooldownSec":90}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tuning, err := LoadVoiceTTSLaunchTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("合法文件: %v", err)
|
||||
}
|
||||
if !tuning.EnabledOn() {
|
||||
t.Fatal("enabled:true 没被读出")
|
||||
}
|
||||
if tuning.ProbeTimeoutMs != 250 || tuning.CooldownSec != 90 {
|
||||
t.Fatalf("超时/冷却没读出: %+v", tuning)
|
||||
}
|
||||
if tuning.ProbeURL != "http://127.0.0.1:9880" {
|
||||
t.Fatalf("probeUrl = %q", tuning.ProbeURL)
|
||||
}
|
||||
if filepath.Base(tuning.ScriptPath) != "一键启动.bat" {
|
||||
t.Fatalf("scriptPath 没读出: %q", tuning.ScriptPath)
|
||||
}
|
||||
|
||||
// ④ 显式 false ⇒ 与"没配过"同效(EnabledOn 为 false)
|
||||
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if tuning, err = LoadVoiceTTSLaunchTuning(path); err != nil || tuning.EnabledOn() {
|
||||
t.Fatalf("显式 false: tuning=%+v err=%v, want 不启用", tuning, err)
|
||||
}
|
||||
|
||||
// ⑤ 坏文件 ⇒ 分类错误(调用方据此 WARN + 不启用),且绝不覆盖原文件
|
||||
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err = LoadVoiceTTSLaunchTuning(path); !errors.Is(err, ErrInvalidVoiceTTSLaunch) {
|
||||
t.Fatalf("坏文件: err=%v, want ErrInvalidVoiceTTSLaunch", err)
|
||||
}
|
||||
if raw, rerr := os.ReadFile(path); rerr != nil || string(raw) != `{"enabled":` {
|
||||
t.Fatalf("坏文件被改写了: %q err=%v", raw, rerr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultVoiceTTSLaunchPath 钉住落点:`$DATA_DIR/config/voice-tts-launch.json`
|
||||
// —— 它是"零新 .env 键"这一条的物理形态(开关只可能来自面板文件)。
|
||||
func TestDefaultVoiceTTSLaunchPath(t *testing.T) {
|
||||
got := DefaultVoiceTTSLaunchPath("/data")
|
||||
want := filepath.Join("/data", "config", "voice-tts-launch.json")
|
||||
if got != want {
|
||||
t.Fatalf("落点 = %q, want %q", got, want)
|
||||
}
|
||||
if VoiceTTSLaunchConfigRelPath != "config/voice-tts-launch.json" {
|
||||
t.Fatalf("相对路径 = %q", VoiceTTSLaunchConfigRelPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 0027 的回滚:只撤结构(删列),**不改写任何一行的数据**。
|
||||
--
|
||||
-- 回滚的代价(如实登记):`temporal` 这一列的值全部丢失。它对本迁移之前写入的行
|
||||
-- 本来就恒为 `''`(未分型),因此丢的只是「本迁移之后提取器标出的时间性位」。
|
||||
--
|
||||
-- 真正需要说明的是**判定行为**:`retention_tier` / `expires_at` 是另两列,本回滚
|
||||
-- **刻意不动它们**。
|
||||
-- - 已被判成 temp 的行(其中一部分是 `temporal=one_off` 带来的)保持原状:读侧
|
||||
-- 时效闸与后台清扫都只认 `expires_at`(纯 SQL,不依赖本列),因此回滚后它们的
|
||||
-- 行为**逐字不变**,到期照旧消失、照旧被清扫;
|
||||
-- - 若回滚时顺手把那些行的 `expires_at` 清空,那是一次 schema 回滚**改数据**,
|
||||
-- 会把已经判定的临时记忆全部升回永久,同时还留在 `retention_tier='temp'` 上
|
||||
-- —— 那正是「temp ⇔ 非空 expires_at」这条不变量的反面。
|
||||
-- 要重判档位就重跑判定:列会以默认值 `''` 重建,历史行回到「未分型」。
|
||||
--
|
||||
-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up),
|
||||
-- 因此无需先删索引(0026 的 down 因为带索引才要以 DROP INDEX 开头)。
|
||||
ALTER TABLE memory_items DROP COLUMN temporal;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 0027_memory_temporal:提取器产出侧的**时间性元数据位**(切片 M45)。
|
||||
--
|
||||
-- 为什么需要这一列(而不是再调一次词项阈值):M37 复审的量化结论是「defer 命中
|
||||
-- 2/500 = 0.4%」不是阈值太严,而是**判定链缺信息位** —— 真正卡住上游的是提取器的
|
||||
-- 改写:一次性事件被写成 30~80 字的第三人称长期陈述,规则闸只看落库正文,因此
|
||||
-- 看不见「这是今天的一次性事件」这一位。实测反证:把 N6 的时点词窗口从 12 放宽到
|
||||
-- 24 字,命中 2→14,多出的 11 条逐条都是**同一长期约定的重复陈述**(「一起晒太阳」
|
||||
-- 暂时搁置 ×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 放宽阈值等于 7 天后
|
||||
-- 把约定本身删掉,违背 fail-safe。所以补的是信息位,不是阈值。
|
||||
--
|
||||
-- 为什么是**列**而不是新增一种 kind:`kind`(关于什么)、`scope`(谁的)、
|
||||
-- `retention_tier`(留多久)、本条 `temporal`(**说的是一次发生的事,还是反复/长期
|
||||
-- 成立的事**)是四根正交的轴。时间性是**提取器对原始对话的判断结果**,它必须能被
|
||||
-- 单独存下来(事后巡检「模型当时标了什么」、以及将来回填),塞进 kind 就丢了这条
|
||||
-- 观测线;靠提示词里的自然语言描述则违反 AGENTS §9「判据靠元数据,不靠提示词」。
|
||||
--
|
||||
-- 列值闭集(代码侧定义:internal/runtime/memory/temporal.go 的 TemporalKind):
|
||||
-- one_off 一次性事件(今天 / 这次 / 某一次)⇒ 判定为 temp + 非空 expires_at
|
||||
-- recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
|
||||
-- durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
|
||||
-- unknown 模型拿不准或没给出合法值 ⇒ **没有结论**,交给既有 N1~N7 规则判
|
||||
-- '' 尚未分型:本迁移之前的历史行,以及非提取器的写入路径(默认值)
|
||||
--
|
||||
-- 🔴 默认值 `''` 而**不是** `unknown`:两者在判定上走同一条路(都落到旧规则),但
|
||||
-- 含义不同 —— `''` 是「没问过」,`unknown` 是「问过,模型拿不准」。把历史行写成
|
||||
-- `unknown` 会让「模型答了什么」这个观测面从第一行起就掺假。本迁移**刻意不回填**:
|
||||
-- 历史行没有对应的提取器输出,拿正文反推「它当时是不是一次性事件」正是本片要消灭
|
||||
-- 的那种猜测。
|
||||
--
|
||||
-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本
|
||||
-- 迁移与 0019 / 0026 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
|
||||
-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚
|
||||
-- (internal/runtime/memory/temporal_schema.go 的 ensureTemporalSchema)走到
|
||||
-- "duplicate column name" 是预期路径。列的形状必须与自举 DDL
|
||||
-- (internal/runtime/memory/store_schema.go)**逐字同形**,由
|
||||
-- internal/runtime/memory/temporal_migration_test.go 钉住。
|
||||
--
|
||||
-- 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入时的判定输入 +
|
||||
-- 事后巡检的留痕列)。将来整理侧真要按它筛,再补索引不迟(两条路径一起加)。
|
||||
--
|
||||
-- 命名口径:表名复数(AGENTS §10 已登记例外)、列名与代码的 JSON 键同名
|
||||
-- (`temporal`),与 0026 的 `retention_tier` 同族。
|
||||
ALTER TABLE memory_items ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
|
||||
@@ -231,7 +231,7 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector,
|
||||
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding: request: %w", err)
|
||||
return nil, fmt.Errorf("embedding: request: %w", trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -298,7 +298,7 @@ func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding: build request: %w", err)
|
||||
return nil, fmt.Errorf("embedding: build request: %w", trace.URLCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if e.cfg.APIKey != "" {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写
|
||||
@@ -39,7 +41,8 @@ const loopbackIPv4 = "127.0.0.1"
|
||||
func normalizeEndpointURL(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("embedding: parse api url: %w", err)
|
||||
// 不回原文:url.Parse 的 *url.Error 会把整条地址(query 里可能是 api_key)渲染进去。
|
||||
return "", fmt.Errorf("embedding: parse api url: %w", trace.URLCause(err))
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
// 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package embedding
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// embedding 这一处一行没改也应当自动安全 —— 兼容层把散文塞进 error.code /
|
||||
// error.type 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
|
||||
// 这一处最敏感:/embeddings 的 input 就是她的记忆正文,回显它的散文不能进日志。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestAPIEmbedder_Non200DropsFreeTextErrorCode 400 报文的 error.code / error.type
|
||||
// 里带散文 ⇒ 两个字段整体消失,而状态码与体积摘要仍在。
|
||||
func TestAPIEmbedder_Non200DropsFreeTextErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
})
|
||||
emb := NewAPI(
|
||||
config.EmbeddingConfig{APIURL: "https://embed.example.com/v1/embeddings"},
|
||||
WithRoundTripper(rt),
|
||||
)
|
||||
|
||||
_, err := emb.Embed(context.Background(), []string{"hi"})
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("embed call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package embedding
|
||||
|
||||
// 本文件是 M52 的出口回归探针:embedding 客户端里「把 net/http 的 *url.Error 直接
|
||||
// 打进错误串」的两处 —— `embedChunk` 的传输失败与 `normalizeEndpointURL` 的解析失败 ——
|
||||
// 一律先过 trace.URLCause。
|
||||
//
|
||||
// 为什么这两处特别敏感:/embeddings 的端点常常配成带 `?api_key=` 的网关地址,而
|
||||
// `client.Do` 失败时 net/http 会把**整条地址**渲染进错误串(密码被打码、用户名与 query
|
||||
// 不打码)。原文进日志就等于把网关凭据交出去。
|
||||
//
|
||||
// 这一包与 model/vision 的差别:这里原本用的是 `%w`(不是 `%v`),因此除了「去地址」,
|
||||
// 还必须钉住 `%w` 的链没被削弱 —— 把 URLCause 换成「新建一个错误」会让
|
||||
// errors.Is(err, context.DeadlineExceeded) 变红。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
urlCauseQuerySecret = "SECRET-2f8a1c"
|
||||
urlCauseUser = "svc-user"
|
||||
urlCauseBadHost = "bad host.example.com"
|
||||
)
|
||||
|
||||
func embedURLCauseGoodURL() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@embed.example.com/v1/embeddings?api_key=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
func embedURLCauseBadURL() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1/embeddings?api_key=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
// TestEmbedDropsURLFromTransportError 钉住 embedChunk 里 `e.client.Do` 失败那一处,
|
||||
// 并钉住 `%w` 的哨兵链仍然成立(context.DeadlineExceeded 可 errors.Is)。
|
||||
func TestEmbedDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
emb := NewAPI(
|
||||
config.EmbeddingConfig{APIURL: embedURLCauseGoodURL()},
|
||||
WithRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("net/http: %w", context.DeadlineExceeded)
|
||||
})),
|
||||
)
|
||||
|
||||
_, err := emb.Embed(context.Background(), []string{"hi"})
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) {
|
||||
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "deadline exceeded") {
|
||||
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
|
||||
}
|
||||
// `%w` 链必须还在:URLCause 只剥 *url.Error 外壳,内层原因原样返回。
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("err = %v, want errors.Is(context.DeadlineExceeded)(哨兵链不许被带偏)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbedDropsURLFromParseError 钉住 normalizeEndpointURL 的解析失败:
|
||||
// url.Parse 的 *url.Error 里就是整条 api URL。
|
||||
func TestEmbedDropsURLFromParseError(t *testing.T) {
|
||||
t.Parallel()
|
||||
emb := NewAPI(config.EmbeddingConfig{APIURL: embedURLCauseBadURL()})
|
||||
|
||||
_, err := emb.Embed(context.Background(), []string{"hi"})
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("解析失败的串带出了 api URL 里的凭据:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse api url") {
|
||||
t.Fatalf("错误串丢了来源标记:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,11 @@ type Result struct {
|
||||
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
|
||||
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。
|
||||
Documents []history.DocumentRef
|
||||
// Voice 是本回合的**出站语音结论**:nil 表示本回合没有请求语音(纯文本);
|
||||
// Name 非空表示合成成功且音频副本已落盘;Requested 为真但 Name 为空、
|
||||
// FallbackReason 非空表示她请求了语音但降级为文本(如 tts_timeout)。
|
||||
// 字段语义详见 OutboundVoice。
|
||||
Voice *OutboundVoice
|
||||
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
|
||||
// Result,绝不跨 Run 共享。
|
||||
docSeen map[string]int
|
||||
@@ -138,7 +143,13 @@ type Loop struct {
|
||||
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
|
||||
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
|
||||
skillFace *SkillFace
|
||||
log *slog.Logger
|
||||
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
|
||||
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
|
||||
// speakBudget 是超时预算的替换公式,nil 用内置公式。
|
||||
voiceSynth VoiceSynth
|
||||
voiceStore VoiceStore
|
||||
speakBudget func(int) int
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时
|
||||
@@ -211,6 +222,9 @@ func New(opts Options) *Loop {
|
||||
imageResolver: opts.Vision,
|
||||
documentResolver: opts.DocumentResolver,
|
||||
documentInjectChars: docInjectChars,
|
||||
voiceSynth: opts.VoiceSynth,
|
||||
voiceStore: opts.VoiceStore,
|
||||
speakBudget: opts.SpeakBudget,
|
||||
log: sourcedLog(opts.Log),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,15 @@ type Options struct {
|
||||
// 或重排模型所见的消息。它是循环中与
|
||||
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
|
||||
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
|
||||
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
|
||||
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
|
||||
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
|
||||
VoiceSynth VoiceSynth
|
||||
VoiceStore VoiceStore
|
||||
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
|
||||
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
|
||||
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
|
||||
SpeakBudget func(runes int) int
|
||||
// Log 是基础 logger。为 nil 时替换为标记来源
|
||||
// "runtime/loop" 的丢弃 logger。
|
||||
Log *slog.Logger
|
||||
|
||||
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
}
|
||||
schemas := d.ToolSchemas()
|
||||
if l.capacity == nil || !l.capacity.Enabled() {
|
||||
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas))
|
||||
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(schemas))
|
||||
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out))
|
||||
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
|
||||
}
|
||||
|
||||
// capacityTexts 为 capacity 判定器提取近期对话文本:
|
||||
|
||||
@@ -187,6 +187,7 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
|
||||
}
|
||||
msgs = batch.messages
|
||||
res.ToolCallsExecuted += batch.executed
|
||||
applyBatchVoice(res, &batch)
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
|
||||
|
||||
// 关键工具循环安全网(或终止钩子)不等真正的最终答案
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package loop
|
||||
|
||||
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
|
||||
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
|
||||
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
|
||||
// 调了 speak = 这条想用语音;不调 = 发文本。
|
||||
//
|
||||
// 三条执行口径:
|
||||
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
|
||||
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
|
||||
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
|
||||
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
|
||||
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
|
||||
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
|
||||
speakToolName = "speak"
|
||||
|
||||
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
|
||||
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
|
||||
speakBudgetBaseMs = 3000
|
||||
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
|
||||
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
|
||||
speakBudgetPerRuneMs = 25
|
||||
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
|
||||
// 绝不让她的一条消息把整个回合挂住。
|
||||
speakBudgetMaxMs = 20000
|
||||
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
|
||||
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
|
||||
speakMaxRunes = 1200
|
||||
|
||||
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
|
||||
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
|
||||
speakAudioOrigName = "speak."
|
||||
|
||||
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
|
||||
// 客户端据此决定怎么显示「这条本来想用语音」)。
|
||||
speakReasonTimeout = "tts_timeout"
|
||||
speakReasonFailed = "tts_failed"
|
||||
speakReasonNoAudio = "tts_no_audio"
|
||||
speakReasonStoreFailed = "tts_store_failed"
|
||||
speakReasonTooLong = "text_too_long"
|
||||
speakReasonInvalidArgs = "invalid_args"
|
||||
)
|
||||
|
||||
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
|
||||
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
|
||||
type speakTool struct {
|
||||
synth VoiceSynth
|
||||
store VoiceStore
|
||||
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
|
||||
budget func(runes int) int
|
||||
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
|
||||
// (发音人绑人格,链本身不动)。
|
||||
persona func(context.Context) string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
|
||||
var _ sdk.Tool = speakTool{}
|
||||
|
||||
// Name 返回工具标识。
|
||||
func (speakTool) Name() string { return speakToolName }
|
||||
|
||||
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
|
||||
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
|
||||
func (speakTool) Description() string {
|
||||
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
|
||||
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
|
||||
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
|
||||
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
|
||||
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
|
||||
}
|
||||
|
||||
// Parameters 返回工具输入的 JSON Schema。
|
||||
func (speakTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
|
||||
},
|
||||
"emotion": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
|
||||
"这个字段只做额外微调,不会换掉发音人。",
|
||||
},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
|
||||
type speakArgs struct {
|
||||
text string
|
||||
emotion string
|
||||
runes int
|
||||
}
|
||||
|
||||
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
|
||||
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
|
||||
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
in, err := parseSpeakArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.synth == nil || t.store == nil {
|
||||
return nil, &speakError{Code: speakReasonFailed,
|
||||
Err: errors.New("speak: voice synthesis is not configured")}
|
||||
}
|
||||
budget := t.budgetMs(in.runes)
|
||||
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
|
||||
defer cancel()
|
||||
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
|
||||
|
||||
started := time.Now()
|
||||
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
|
||||
cost := int(time.Since(started) / time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
|
||||
}
|
||||
ext, extOK := speakExt(audio.Format)
|
||||
if len(audio.Data) == 0 || !extOK {
|
||||
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
|
||||
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
|
||||
}
|
||||
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
|
||||
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
|
||||
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
|
||||
if err != nil {
|
||||
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
|
||||
}
|
||||
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
|
||||
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
|
||||
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
|
||||
return map[string]any{
|
||||
"name": ref.Name,
|
||||
"mime": ref.MIME,
|
||||
"size": ref.Size,
|
||||
"durationMs": int64(0),
|
||||
"budgetMs": budget,
|
||||
"costMs": cost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
|
||||
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
|
||||
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
|
||||
if t.persona != nil {
|
||||
if pid := t.persona(turnCtx); pid != "" {
|
||||
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
|
||||
}
|
||||
}
|
||||
if emotion != "" {
|
||||
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
|
||||
}
|
||||
return synthCtx
|
||||
}
|
||||
|
||||
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
|
||||
func (t speakTool) budgetMs(runes int) int {
|
||||
if t.budget != nil {
|
||||
if ms := t.budget(runes); ms > 0 {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
return speakBudgetMs(runes)
|
||||
}
|
||||
|
||||
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
|
||||
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
|
||||
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
|
||||
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
|
||||
code := speakReasonFailed
|
||||
if errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
|
||||
code = speakReasonTimeout
|
||||
}
|
||||
if t.log != nil {
|
||||
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
|
||||
t.log.Warn("speak degraded to text",
|
||||
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
|
||||
}
|
||||
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
|
||||
}
|
||||
|
||||
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
|
||||
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
|
||||
if t.log == nil {
|
||||
return
|
||||
}
|
||||
t.log.Info("speak synthesized",
|
||||
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
|
||||
}
|
||||
|
||||
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
|
||||
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
|
||||
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
|
||||
type speakError struct {
|
||||
Code string
|
||||
BudgetMs int
|
||||
CostMs int
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
|
||||
func (e *speakError) Error() string {
|
||||
if e.Err == nil {
|
||||
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
|
||||
}
|
||||
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
|
||||
func (e *speakError) Unwrap() error { return e.Err }
|
||||
|
||||
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
|
||||
// 归到 argument-validation 而不是执行失败。
|
||||
func parseSpeakArgs(args any) (speakArgs, error) {
|
||||
m, err := speakArgMap(args)
|
||||
if err != nil {
|
||||
return speakArgs{}, err
|
||||
}
|
||||
text, _ := m["text"].(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
|
||||
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
|
||||
}
|
||||
runes := utf8.RuneCountInString(text)
|
||||
if runes > speakMaxRunes {
|
||||
return speakArgs{}, &speakError{Code: speakReasonTooLong,
|
||||
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
|
||||
}
|
||||
emotion, _ := m["emotion"].(string)
|
||||
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
|
||||
}
|
||||
|
||||
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
|
||||
func speakArgMap(args any) (map[string]any, error) {
|
||||
if args == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
return nil, &speakError{Code: speakReasonInvalidArgs,
|
||||
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
|
||||
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
|
||||
func speakBudgetMs(runes int) int {
|
||||
if runes < 0 {
|
||||
runes = 0
|
||||
}
|
||||
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
|
||||
if ms > speakBudgetMaxMs {
|
||||
return speakBudgetMaxMs
|
||||
}
|
||||
if ms < speakBudgetBaseMs {
|
||||
return speakBudgetBaseMs
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
|
||||
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
|
||||
// 绝不拿一个读不出 Content-Type 的名字落盘。
|
||||
func speakExt(format string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "":
|
||||
return "wav", true
|
||||
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
|
||||
return strings.ToLower(strings.TrimSpace(format)), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
|
||||
func (l *Loop) speakSpec() *model.ToolSpec {
|
||||
if !l.voiceEnabled() {
|
||||
return nil
|
||||
}
|
||||
t := l.speakTool()
|
||||
params, err := t.Parameters()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
|
||||
}
|
||||
|
||||
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
|
||||
func (l *Loop) speakTool() speakTool {
|
||||
return speakTool{
|
||||
synth: l.voiceSynth,
|
||||
store: l.voiceStore,
|
||||
budget: l.speakBudget,
|
||||
persona: l.personaFor,
|
||||
log: l.log,
|
||||
}
|
||||
}
|
||||
|
||||
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
|
||||
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
|
||||
// panic 兜底与允许集软判对普通工具仍然生效。
|
||||
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
|
||||
if tc.Name == speakToolName && l.voiceEnabled() {
|
||||
return l.speakTool().Execute(ctx, tc.Args)
|
||||
}
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
|
||||
}
|
||||
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
|
||||
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
|
||||
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
|
||||
|
||||
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
|
||||
// audio 决定成功时的产物,provider 供日志断言。
|
||||
type stubSynth struct {
|
||||
audio mvoice.Audio
|
||||
provider string
|
||||
err error
|
||||
delay time.Duration
|
||||
gotText string
|
||||
}
|
||||
|
||||
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
|
||||
s.gotText = text
|
||||
if s.delay > 0 {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
case <-ctx.Done():
|
||||
return mvoice.Audio{}, "", ctx.Err()
|
||||
}
|
||||
}
|
||||
if s.err != nil {
|
||||
return mvoice.Audio{}, "", s.err
|
||||
}
|
||||
return s.audio, s.provider, nil
|
||||
}
|
||||
|
||||
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
|
||||
type stubStore struct {
|
||||
ref VoiceRef
|
||||
err error
|
||||
gotName string
|
||||
gotData []byte
|
||||
}
|
||||
|
||||
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
s.gotName = origName
|
||||
s.gotData = data
|
||||
if s.err != nil {
|
||||
return VoiceRef{}, s.err
|
||||
}
|
||||
return s.ref, nil
|
||||
}
|
||||
|
||||
// newSpeakTool 装配一个注入了桩的 speak 工具。
|
||||
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
|
||||
return speakTool{synth: synth, store: store}
|
||||
}
|
||||
|
||||
// speakCodeOf 取出 speak 失败的结构化原因码。
|
||||
func speakCodeOf(t *testing.T, err error) *speakError {
|
||||
t.Helper()
|
||||
var se *speakError
|
||||
if !errors.As(err, &se) {
|
||||
t.Fatalf("error %v is not *speakError", err)
|
||||
}
|
||||
return se
|
||||
}
|
||||
|
||||
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
|
||||
// 长文封顶,负数按 0 处理(绝不返回负预算)。
|
||||
func TestSpeakBudgetMs(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
runes int
|
||||
want int
|
||||
}{
|
||||
{-5, speakBudgetBaseMs},
|
||||
{0, speakBudgetBaseMs},
|
||||
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
|
||||
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
|
||||
{680, speakBudgetMaxMs},
|
||||
{speakMaxRunes, speakBudgetMaxMs},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := speakBudgetMs(c.runes); got != c.want {
|
||||
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
|
||||
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
|
||||
// 名字落盘)。
|
||||
func TestSpeakExt(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
format string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"", "wav", true},
|
||||
{" ", "wav", true},
|
||||
{"WAV", "wav", true},
|
||||
{"mp3", "mp3", true},
|
||||
{" Opus ", "", false},
|
||||
{"exe", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := speakExt(c.format)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
|
||||
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
|
||||
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
|
||||
func TestParseSpeakArgs_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
|
||||
}
|
||||
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
|
||||
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
|
||||
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
|
||||
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
|
||||
}
|
||||
|
||||
// 恰好上限、且是多字节:按 rune 计数,应当放行。
|
||||
in, err := parseSpeakArgs(map[string]any{
|
||||
"text": strings.Repeat("あ", speakMaxRunes),
|
||||
"emotion": " 温柔 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("at-limit runes rejected: %v", err)
|
||||
}
|
||||
if in.runes != speakMaxRunes {
|
||||
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
|
||||
}
|
||||
if in.emotion != "温柔" {
|
||||
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
|
||||
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
|
||||
// 且 durationMs 恒为 0(未解析,不是零长度)。
|
||||
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
synth := &stubSynth{
|
||||
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
|
||||
provider: "emotiontts",
|
||||
}
|
||||
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
|
||||
tk := newSpeakTool(synth, store)
|
||||
tk.budget = func(int) int { return 777 }
|
||||
|
||||
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if synth.gotText != "晚安" {
|
||||
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
|
||||
}
|
||||
if store.gotName != "speak.wav" {
|
||||
t.Fatalf("origName = %q, want speak.wav", store.gotName)
|
||||
}
|
||||
if len(store.gotData) != 3 {
|
||||
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
|
||||
}
|
||||
m, ok := out.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("result type = %T, want map[string]any", out)
|
||||
}
|
||||
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
|
||||
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
|
||||
}
|
||||
if m["durationMs"] != int64(0) {
|
||||
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
|
||||
}
|
||||
if m["budgetMs"] != 777 {
|
||||
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
|
||||
}
|
||||
if _, ok := m["costMs"].(int); !ok {
|
||||
t.Fatalf("costMs = %v, want int", m["costMs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
|
||||
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
|
||||
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
|
||||
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
|
||||
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
tk := newSpeakTool(
|
||||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
|
||||
&stubStore{},
|
||||
)
|
||||
tk.budget = func(int) int { return 1 }
|
||||
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
se := speakCodeOf(t, err)
|
||||
if se.Code != speakReasonTimeout {
|
||||
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
|
||||
}
|
||||
if se.BudgetMs != 1 {
|
||||
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
|
||||
}
|
||||
if se.CostMs < 0 {
|
||||
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
|
||||
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
|
||||
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
audio mvoice.Audio
|
||||
}{
|
||||
{"empty bytes", mvoice.Audio{Format: "wav"}},
|
||||
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
|
||||
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
|
||||
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
|
||||
}
|
||||
if store.gotName != "" {
|
||||
t.Fatalf("store was called with %q, want no write at all", store.gotName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
|
||||
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
|
||||
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
|
||||
t.Parallel()
|
||||
tk := newSpeakTool(
|
||||
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
|
||||
&stubStore{err: errors.New("disk full")},
|
||||
)
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
se := speakCodeOf(t, err)
|
||||
if se.Code != speakReasonStoreFailed {
|
||||
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
|
||||
}
|
||||
if !errors.Is(err, se.Err) {
|
||||
t.Fatalf("error chain lost the store error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
|
||||
// 以结构化失败返回,使回合仍能继续。
|
||||
func TestSpeakTool_NotConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tk := range []speakTool{
|
||||
newSpeakTool(nil, &stubStore{}),
|
||||
newSpeakTool(&stubSynth{}, nil),
|
||||
} {
|
||||
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
|
||||
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
|
||||
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
|
||||
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
|
||||
func TestSpeakSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
if spec := New(Options{}).speakSpec(); spec != nil {
|
||||
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
|
||||
}
|
||||
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
|
||||
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
|
||||
}
|
||||
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
|
||||
if spec == nil {
|
||||
t.Fatal("wired speakSpec = nil, want a spec")
|
||||
}
|
||||
if spec.Name != speakToolName {
|
||||
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
|
||||
}
|
||||
if spec.Parameters == nil {
|
||||
t.Fatal("spec parameters = nil, want the JSON schema")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
|
||||
// (因此不受前面任何收缩影响)、已有同名不重复追加。
|
||||
func TestWithSpeakSpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
|
||||
|
||||
unwired := New(Options{})
|
||||
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
|
||||
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
|
||||
}
|
||||
|
||||
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
|
||||
got := wired.withSpeakSpec(base)
|
||||
if len(got) != len(base)+1 {
|
||||
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
|
||||
}
|
||||
if last := got[len(got)-1].Name; last != speakToolName {
|
||||
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
|
||||
}
|
||||
|
||||
again := wired.withSpeakSpec(got)
|
||||
if len(again) != len(got) {
|
||||
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package loop
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
@@ -128,6 +129,37 @@ func notAllowedReason(name string) string {
|
||||
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
|
||||
}
|
||||
|
||||
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
|
||||
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
|
||||
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
|
||||
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
|
||||
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
|
||||
//
|
||||
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
|
||||
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
|
||||
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
|
||||
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
|
||||
if !l.voiceEnabled() {
|
||||
return
|
||||
}
|
||||
env := sessionEnvFrom(ctx)
|
||||
if env.ActorEnv == restrict.ActorOwner {
|
||||
return
|
||||
}
|
||||
for i := range calls {
|
||||
if !actions[i].run || calls[i].Name != speakToolName {
|
||||
continue
|
||||
}
|
||||
reason := notAllowedReason(calls[i].Name)
|
||||
actions[i] = toolCallAction{run: false, reason: reason}
|
||||
l.emitAudit(AuditEvent{
|
||||
Who: string(env.ActorEnv),
|
||||
What: auditToolRejectNotAllowed,
|
||||
Result: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
|
||||
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中
|
||||
// 安全门仍能工作(软 not_allowed 结果)。
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
@@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
|
||||
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
|
||||
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
|
||||
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
|
||||
// 主人环境照常放行。
|
||||
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var audited []AuditEvent
|
||||
l := New(Options{
|
||||
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
|
||||
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
|
||||
OnAudit: func(ev AuditEvent) {
|
||||
mu.Lock()
|
||||
audited = append(audited, ev)
|
||||
mu.Unlock()
|
||||
},
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
env restrict.ActorEnv
|
||||
allow bool
|
||||
}{
|
||||
{restrict.ActorGroupMember, false},
|
||||
{restrict.ActorStranger, false},
|
||||
{restrict.ActorOwner, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
mu.Lock()
|
||||
audited = nil
|
||||
mu.Unlock()
|
||||
|
||||
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
|
||||
actions := []toolCallAction{{run: true}}
|
||||
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
|
||||
|
||||
if actions[0].run != tc.allow {
|
||||
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
|
||||
}
|
||||
mu.Lock()
|
||||
got := append([]AuditEvent(nil), audited...)
|
||||
mu.Unlock()
|
||||
if tc.allow {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("env %s audited %+v, want none", tc.env, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(actions[0].reason, "not_allowed") {
|
||||
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
|
||||
}
|
||||
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
|
||||
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
|
||||
}
|
||||
}
|
||||
|
||||
// speak 未接线时没有可执行的旁路,门不该动任何调用。
|
||||
actions := []toolCallAction{{run: true}}
|
||||
New(Options{}).applyToolAdmissions(
|
||||
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
|
||||
nil,
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
actions,
|
||||
)
|
||||
if !actions[0].run {
|
||||
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
|
||||
}
|
||||
}
|
||||
|
||||
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
|
||||
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
|
||||
t.Helper()
|
||||
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
|
||||
@@ -77,6 +75,9 @@ type toolRunResult struct {
|
||||
messages []model.Message
|
||||
executed int
|
||||
terminate bool
|
||||
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
|
||||
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
|
||||
voice *OutboundVoice
|
||||
}
|
||||
|
||||
// toolLoopState 携带关键工具循环安全网的每次 run 状态。
|
||||
@@ -156,6 +157,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
|
||||
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
|
||||
l.applyToolOutcomeHooks(ctx, calls, outcomes)
|
||||
|
||||
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
|
||||
// 出站结论要看**模型最终看到的那份**。
|
||||
res.voice = l.captureVoice(calls, outcomes)
|
||||
|
||||
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
|
||||
// (OpenClaw 的 shouldTerminateToolBatch)。
|
||||
if !res.terminate && shouldTerminateBatch(outcomes) {
|
||||
@@ -231,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
|
||||
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
|
||||
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
|
||||
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
|
||||
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证
|
||||
// 此时整批以错误回灌)。
|
||||
// 逐调用否决。
|
||||
//
|
||||
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
|
||||
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
|
||||
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
|
||||
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
|
||||
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
|
||||
// 的旁路。
|
||||
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
|
||||
if d != nil {
|
||||
l.applyToolSecurityGate(ctx, d, calls, actions)
|
||||
} else {
|
||||
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
|
||||
}
|
||||
if l.hooks.BatchAdmit != nil {
|
||||
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
|
||||
@@ -287,95 +300,6 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
|
||||
}
|
||||
}
|
||||
|
||||
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
|
||||
// tool span、以 tool_execution.start/end 事件流式输出执行进度
|
||||
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
|
||||
// running,Invoke 返回后按成败取 done/error,同一取值既决定
|
||||
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
|
||||
// running/done/error 状态即由此驱动)。
|
||||
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
|
||||
if d == nil {
|
||||
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
|
||||
}
|
||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
|
||||
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
|
||||
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
|
||||
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
|
||||
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
|
||||
// 不做互斥,行为逐字不变(兼容硬要求)。
|
||||
//
|
||||
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
|
||||
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
|
||||
// 「1 普通回合 + 1 并行路」时完全不受保护。
|
||||
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
|
||||
phase := model.ToolPhaseDone
|
||||
if err != nil {
|
||||
phase = model.ToolPhaseError
|
||||
}
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
|
||||
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
|
||||
l.spanProp(toolSpan, "phase", phase)
|
||||
l.recordToolDone(toolSpan, tc, out)
|
||||
if err != nil {
|
||||
l.spanProp(toolSpan, "error", err.Error())
|
||||
l.spanProp(toolSpan, "error_kind", out.ErrKind)
|
||||
} else {
|
||||
recordToolResult(toolSpan, result)
|
||||
}
|
||||
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
|
||||
return out
|
||||
}
|
||||
|
||||
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
|
||||
// tool_execution 三事件(start / update / end)转发给前端;否则为
|
||||
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
|
||||
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
|
||||
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
|
||||
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
l.onStream(ev)
|
||||
}
|
||||
|
||||
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
|
||||
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
|
||||
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
|
||||
func spanStatusForToolPhase(phase string) trace.SpanStatus {
|
||||
if phase == model.ToolPhaseError {
|
||||
return trace.SpanStatusError
|
||||
}
|
||||
return trace.SpanStatusOK
|
||||
}
|
||||
|
||||
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
|
||||
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
|
||||
// 能按原始顺序渲染结果。信号量限制并发,在途
|
||||
// goroutine 完成时释放其槽位,因此被取消的 context
|
||||
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
|
||||
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
|
||||
if len(idxs) == 0 {
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, l.toolParallelism)
|
||||
var wg sync.WaitGroup
|
||||
for _, i := range idxs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = l.invokeOne(ctx, d, calls[i])
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// 由 classifyToolBatch 为两种跳过原因返回。
|
||||
func duplicateCallReason() string {
|
||||
return "duplicate call with identical arguments already issued in this batch"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package loop
|
||||
|
||||
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
|
||||
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
|
||||
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
|
||||
// tool span、以 tool_execution.start/end 事件流式输出执行进度
|
||||
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
|
||||
// running,Invoke 返回后按成败取 done/error,同一取值既决定
|
||||
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
|
||||
// running/done/error 状态即由此驱动)。
|
||||
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
|
||||
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
|
||||
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
|
||||
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
|
||||
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
|
||||
}
|
||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
|
||||
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
|
||||
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
|
||||
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
|
||||
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
|
||||
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
|
||||
//
|
||||
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
|
||||
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
|
||||
// 「1 普通回合 + 1 并行路」时完全不受保护。
|
||||
result, err := l.invokeTool(ctx, d, tc)
|
||||
phase := model.ToolPhaseDone
|
||||
if err != nil {
|
||||
phase = model.ToolPhaseError
|
||||
}
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
|
||||
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
|
||||
l.spanProp(toolSpan, "phase", phase)
|
||||
l.recordToolDone(toolSpan, tc, out)
|
||||
if err != nil {
|
||||
l.spanProp(toolSpan, "error", err.Error())
|
||||
l.spanProp(toolSpan, "error_kind", out.ErrKind)
|
||||
} else {
|
||||
recordToolResult(toolSpan, result)
|
||||
}
|
||||
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
|
||||
return out
|
||||
}
|
||||
|
||||
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
|
||||
// tool_execution 三事件(start / update / end)转发给前端;否则为
|
||||
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
|
||||
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
|
||||
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
|
||||
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
l.onStream(ev)
|
||||
}
|
||||
|
||||
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
|
||||
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
|
||||
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
|
||||
func spanStatusForToolPhase(phase string) trace.SpanStatus {
|
||||
if phase == model.ToolPhaseError {
|
||||
return trace.SpanStatusError
|
||||
}
|
||||
return trace.SpanStatusOK
|
||||
}
|
||||
|
||||
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
|
||||
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
|
||||
// 能按原始顺序渲染结果。信号量限制并发,在途
|
||||
// goroutine 完成时释放其槽位,因此被取消的 context
|
||||
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
|
||||
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
|
||||
if len(idxs) == 0 {
|
||||
return
|
||||
}
|
||||
sem := make(chan struct{}, l.toolParallelism)
|
||||
var wg sync.WaitGroup
|
||||
for _, i := range idxs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
out[i] = l.invokeOne(ctx, d, calls[i])
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package loop
|
||||
|
||||
// OutboundVoice 是一次 Run 的**出站语音结论**:模型在回合内调用 speak 工具
|
||||
// 请求合成语音时,循环把这次请求的结局归一化到本结构,随 Result 交还调用方
|
||||
// (网关据此决定这条回复是发音频还是发文本,持久化层据此记下「这条是语音」
|
||||
// 与降级原因)。它是进程内类型,不直接序列化给客户端。
|
||||
//
|
||||
// 三种状态互斥且由字段组合判定:
|
||||
//
|
||||
// - Result.Voice == nil —— 本回合**没有**请求语音,回复就是纯文本;
|
||||
// - Name != "" —— 合成成功,且音频副本**已经落盘**(Name 即服务端存储名,
|
||||
// Mime/Size 描述该副本);
|
||||
// - Requested && Name == "" && FallbackReason != "" —— 她**请求了**语音,
|
||||
// 但合成降级为文本(如 tts_timeout);FallbackReason 是机器可读的降级码,
|
||||
// 客户端可以据此特殊显示「这条本来想用语音」。
|
||||
//
|
||||
// 语音是她的表达选择,不是身份或权限判定 —— 谁能用语音仍由渠道层 actor
|
||||
// 元数据 + 工具可达性标记决定,本结构只记录结局。
|
||||
type OutboundVoice struct {
|
||||
// Requested 报告本回合是否发起过语音合成请求(成功与降级都为 true)。
|
||||
Requested bool
|
||||
// Name 是落盘音频副本的服务端存储名;空表示没有可用音频(降级或未请求)。
|
||||
Name string
|
||||
// Mime 是音频副本的媒体类型(如 audio/wav),随 Name 一起有意义。
|
||||
Mime string
|
||||
// Size 是音频副本的字节数。
|
||||
Size int64
|
||||
// DurationMs 是音频时长(毫秒)。**不知道就是 0** —— 本项目绝不用比特率
|
||||
// 或字数估算时长(与 voice.Copy.DurationMs 同一口径);0 表示「未解析」,
|
||||
// 不表示零长度音频。
|
||||
DurationMs int64
|
||||
// CostMs 是这次合成实际耗时(毫秒),用于对照预算看是否接近超时。
|
||||
CostMs int
|
||||
// BudgetMs 是本次合成被允许的超时预算(毫秒),由文本 rune 数推导
|
||||
// (见 speakBudgetMs);未请求时为 0。
|
||||
BudgetMs int
|
||||
// FallbackReason 是降级原因码(如 tts_timeout),仅在「请求了但没有音频」
|
||||
// 时非空;它让调用方与客户端能把「想用语音但没成」与「本来就用文本」区分开。
|
||||
FallbackReason string
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package loop
|
||||
|
||||
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
|
||||
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
|
||||
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
|
||||
// 预算口径,而这两样只有循环持有。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
|
||||
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
|
||||
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
|
||||
type VoiceSynth interface {
|
||||
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
|
||||
// (降级后是备用的那个),供日志与可观测性使用。
|
||||
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
|
||||
}
|
||||
|
||||
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
|
||||
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
|
||||
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
|
||||
// import 循环,反向依赖构成测试期的 import cycle。
|
||||
type VoiceRef struct {
|
||||
// Name 是服务端存储名(客户端凭它取回音频)。
|
||||
Name string
|
||||
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
|
||||
MIME string
|
||||
// Size 是该副本的字节数。
|
||||
Size int64
|
||||
}
|
||||
|
||||
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
|
||||
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
|
||||
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
|
||||
type VoiceStore interface {
|
||||
// SaveAudio 落盘一份音频副本并返回其交付描述。
|
||||
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
|
||||
}
|
||||
|
||||
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
|
||||
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
|
||||
//
|
||||
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
|
||||
// ref, err := docs.SaveAudio(ctx, name, data)
|
||||
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
|
||||
// })
|
||||
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
|
||||
|
||||
// SaveAudio 实现 VoiceStore。
|
||||
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
return f(ctx, origName, data)
|
||||
}
|
||||
|
||||
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
|
||||
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
|
||||
// 让模型面与接线前逐字节一致。
|
||||
func (l *Loop) voiceEnabled() bool {
|
||||
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
|
||||
}
|
||||
|
||||
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
|
||||
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
|
||||
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
|
||||
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
|
||||
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
|
||||
// Policy.Allow 以「未知工具」拒绝,fail-closed。
|
||||
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
|
||||
spec := l.speakSpec()
|
||||
if spec == nil {
|
||||
return schemas
|
||||
}
|
||||
for i := range schemas {
|
||||
if schemas[i].Name == spec.Name {
|
||||
return schemas
|
||||
}
|
||||
}
|
||||
out := make([]model.ToolSpec, 0, len(schemas)+1)
|
||||
out = append(out, schemas...)
|
||||
return append(out, *spec)
|
||||
}
|
||||
|
||||
// applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。
|
||||
// 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」
|
||||
// 发生在哪一批都算数。
|
||||
//
|
||||
// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的
|
||||
// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次
|
||||
// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮
|
||||
// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回
|
||||
// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖:
|
||||
// 后到的成功就是这条消息最终的语音。
|
||||
func applyBatchVoice(res *Result, batch *toolRunResult) {
|
||||
if batch.voice == nil {
|
||||
return
|
||||
}
|
||||
if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" {
|
||||
return
|
||||
}
|
||||
res.Voice = batch.voice
|
||||
}
|
||||
|
||||
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
|
||||
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
|
||||
// 文本呈现 —— 与没有 speak 之前逐字节一致。
|
||||
//
|
||||
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
|
||||
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
|
||||
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
|
||||
// 文本」。
|
||||
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
|
||||
var lastOK, lastFail *OutboundVoice
|
||||
n := len(calls)
|
||||
if len(outcomes) < n {
|
||||
n = len(outcomes)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if calls[i].Name != speakToolName || outcomes[i].Skipped {
|
||||
continue
|
||||
}
|
||||
if outcomes[i].Err == nil {
|
||||
lastOK = synthesizedVoice(outcomes[i].Result)
|
||||
continue
|
||||
}
|
||||
lastFail = degradedVoice(outcomes[i].Err)
|
||||
}
|
||||
if lastOK != nil {
|
||||
return lastOK
|
||||
}
|
||||
return lastFail
|
||||
}
|
||||
|
||||
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
|
||||
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
|
||||
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
|
||||
func synthesizedVoice(result any) *OutboundVoice {
|
||||
m, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v := &OutboundVoice{Requested: true}
|
||||
v.Name, _ = m["name"].(string)
|
||||
v.Mime, _ = m["mime"].(string)
|
||||
v.Size, _ = m["size"].(int64)
|
||||
v.DurationMs, _ = m["durationMs"].(int64)
|
||||
v.CostMs, _ = m["costMs"].(int)
|
||||
v.BudgetMs, _ = m["budgetMs"].(int)
|
||||
return v
|
||||
}
|
||||
|
||||
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
|
||||
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
|
||||
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
|
||||
func degradedVoice(err error) *OutboundVoice {
|
||||
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
|
||||
var se *speakError
|
||||
if !errors.As(err, &se) {
|
||||
return v
|
||||
}
|
||||
if se.Code != "" {
|
||||
v.FallbackReason = se.Code
|
||||
}
|
||||
v.BudgetMs = se.BudgetMs
|
||||
v.CostMs = se.CostMs
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
|
||||
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
|
||||
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
|
||||
|
||||
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
|
||||
// 预算/耗时、没调(或只调了别的工具)返回 nil。
|
||||
func TestCaptureVoice_States(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(Options{})
|
||||
|
||||
if v := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: "terminal"}},
|
||||
[]ToolOutcome{{Result: "ok"}},
|
||||
); v != nil {
|
||||
t.Fatalf("non-speak batch voice = %+v, want nil", v)
|
||||
}
|
||||
|
||||
ok := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
[]ToolOutcome{{Result: map[string]any{
|
||||
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
|
||||
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
|
||||
}}},
|
||||
)
|
||||
if ok == nil || !ok.Requested {
|
||||
t.Fatalf("success voice = %+v, want Requested", ok)
|
||||
}
|
||||
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
|
||||
t.Fatalf("success voice = %+v, want the stored ref", ok)
|
||||
}
|
||||
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
|
||||
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
|
||||
}
|
||||
if ok.FallbackReason != "" {
|
||||
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
|
||||
}
|
||||
|
||||
bad := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
|
||||
)
|
||||
if bad == nil || !bad.Requested {
|
||||
t.Fatalf("failure voice = %+v, want Requested", bad)
|
||||
}
|
||||
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
|
||||
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
|
||||
}
|
||||
if bad.Name != "" {
|
||||
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
|
||||
}
|
||||
|
||||
// 非结构化失败也要有原因码,绝不把空串交给客户端。
|
||||
raw := l.captureVoice(
|
||||
[]agent.ToolCall{{Name: speakToolName}},
|
||||
[]ToolOutcome{{Err: errors.New("boom")}},
|
||||
)
|
||||
if raw == nil || raw.FallbackReason != speakReasonFailed {
|
||||
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
|
||||
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
|
||||
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(Options{})
|
||||
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
|
||||
|
||||
// 先成功、后失败:交付已落盘的那份。
|
||||
got := l.captureVoice(calls[:2], []ToolOutcome{
|
||||
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
|
||||
{Err: &speakError{Code: speakReasonFailed}},
|
||||
})
|
||||
if got == nil || got.Name != "first.wav" {
|
||||
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
|
||||
}
|
||||
|
||||
// 两次都成功:取后者。
|
||||
got = l.captureVoice(calls[:2], []ToolOutcome{
|
||||
{Result: map[string]any{"name": "first.wav"}},
|
||||
{Result: map[string]any{"name": "second.wav"}},
|
||||
})
|
||||
if got == nil || got.Name != "second.wav" {
|
||||
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
|
||||
}
|
||||
|
||||
// 被跳过的调用不算数。
|
||||
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
|
||||
t.Fatalf("skipped voice = %+v, want nil", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
|
||||
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
|
||||
func TestRun_VoiceResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
|
||||
ID: "call_speak_1",
|
||||
Name: speakToolName,
|
||||
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
|
||||
}}}
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||||
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
|
||||
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
|
||||
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, td),
|
||||
MaxRounds: 8,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
VoiceSynth: synth,
|
||||
VoiceStore: store,
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
||||
}
|
||||
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
|
||||
if len(ad.streamReqs) == 0 {
|
||||
t.Fatal("no streaming request captured")
|
||||
}
|
||||
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
|
||||
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
|
||||
}
|
||||
// ② 调用真的走了注入的合成与落盘。
|
||||
if synth.gotText != "晚安" {
|
||||
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
|
||||
}
|
||||
if store.gotName != "speak.wav" {
|
||||
t.Fatalf("origName = %q, want speak.wav", store.gotName)
|
||||
}
|
||||
// ③ 结论落在 Result.Voice 上。
|
||||
if res.Voice == nil {
|
||||
t.Fatal("Result.Voice = nil, want the synthesized audio")
|
||||
}
|
||||
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
|
||||
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
|
||||
}
|
||||
if res.Voice.FallbackReason != "" {
|
||||
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
|
||||
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
|
||||
func TestRun_VoiceDegraded(t *testing.T) {
|
||||
t.Parallel()
|
||||
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
|
||||
ID: "call_speak_2",
|
||||
Name: speakToolName,
|
||||
Args: map[string]any{"text": "晚安"},
|
||||
}}}
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t)),
|
||||
MaxRounds: 8,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
|
||||
VoiceStore: &stubStore{},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "晚安" {
|
||||
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
|
||||
}
|
||||
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
|
||||
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
|
||||
}
|
||||
if res.Voice.Requested != true {
|
||||
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
|
||||
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
|
||||
func TestRun_NoSpeakNoVoice(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
|
||||
l := New(Options{
|
||||
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
|
||||
MaxRounds: 8,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Voice != nil {
|
||||
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
|
||||
}
|
||||
if len(ad.streamReqs) == 0 {
|
||||
t.Fatal("no streaming request captured")
|
||||
}
|
||||
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
|
||||
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
|
||||
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
|
||||
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
wired := New(Options{
|
||||
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
|
||||
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
|
||||
})
|
||||
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
|
||||
Name: speakToolName, Args: map[string]any{"text": "晚安"},
|
||||
})
|
||||
if out.Err != nil {
|
||||
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
|
||||
}
|
||||
|
||||
plain := New(Options{})
|
||||
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
|
||||
if out.Err == nil || out.ErrKind != tool.KindExecution {
|
||||
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
|
||||
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
|
||||
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
|
||||
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
|
||||
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
|
||||
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
|
||||
|
||||
res := &Result{}
|
||||
applyBatchVoice(res, &toolRunResult{voice: delivered})
|
||||
applyBatchVoice(res, &toolRunResult{voice: degraded})
|
||||
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
|
||||
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
|
||||
}
|
||||
|
||||
// 先降级后成功:后到的成功就是这条消息最终的语音。
|
||||
res = &Result{}
|
||||
applyBatchVoice(res, &toolRunResult{voice: degraded})
|
||||
applyBatchVoice(res, &toolRunResult{voice: delivered})
|
||||
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
|
||||
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
|
||||
}
|
||||
|
||||
// 后一批没有 speak:前面那批的结论照旧算数。
|
||||
res = &Result{}
|
||||
applyBatchVoice(res, &toolRunResult{voice: delivered})
|
||||
applyBatchVoice(res, &toolRunResult{})
|
||||
if res.Voice == nil || res.Voice.Name != "abc.wav" {
|
||||
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
|
||||
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
|
||||
// 而不让循环反向 import documents)。
|
||||
func TestVoiceStoreFunc(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotName string
|
||||
var gotData []byte
|
||||
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
|
||||
gotName, gotData = origName, data
|
||||
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
|
||||
})
|
||||
|
||||
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveAudio: %v", err)
|
||||
}
|
||||
if gotName != "speak.wav" || len(gotData) != 2 {
|
||||
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
|
||||
}
|
||||
if ref.Name != "saved.wav" || ref.Size != 2 {
|
||||
t.Fatalf("ref = %+v, want the adapter's own result", ref)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,8 @@ const (
|
||||
//
|
||||
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
|
||||
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
|
||||
// 🔴 时间性元数据位(temporal,迁移 0027)**不是**它的入参:r1 复审裁决 D 之后
|
||||
// 这一位只观测、不参与判定(见 retention.go 的 resolveRetention 头注与 temporal.go)。
|
||||
// 🔴 全仓**唯一**调用点是 `resolveRetention`(retention.go):任何地方再跑一遍
|
||||
// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因,
|
||||
// retention_test.go 有一条源码级断言钉住这个唯一性。
|
||||
@@ -259,6 +261,9 @@ func judgeRetentionOneOff(text string) retentionRule {
|
||||
// ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安…
|
||||
// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点
|
||||
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
|
||||
// 🆕 **M45 曾把这一条接到时间性元数据位上,r1 复审裁决 D 又撤掉了**(原因见
|
||||
// temporal.go 头注:位置放宽能翻的行逐条都是长期约定)。因此位置判据对**每一行**
|
||||
// 无条件生效,与 0027 之前逐字相同。
|
||||
//
|
||||
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
|
||||
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
|
||||
|
||||
@@ -29,6 +29,15 @@ import (
|
||||
//
|
||||
// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples
|
||||
// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。
|
||||
//
|
||||
// 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。
|
||||
// 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为
|
||||
// 四个枚举之一,落库是 `temporal` 列(迁移 0027)。
|
||||
//
|
||||
// 🔴 **这一位目前不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,判定的
|
||||
// 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go
|
||||
// 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 prompt
|
||||
// 的**唯一**作用是让观测面有数据;即便模型完全不听,判定的行为与今天一致。
|
||||
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
|
||||
|
||||
只保留这些(正向清单):
|
||||
@@ -49,18 +58,31 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得
|
||||
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
|
||||
[],不要为了凑数而提取。
|
||||
|
||||
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
|
||||
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
|
||||
即使你已经把它写成「用户……」这样的陈述句;
|
||||
- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…);
|
||||
- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份;
|
||||
- "unknown":拿不准,或者上面三个都不贴切。
|
||||
|
||||
temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填
|
||||
"unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。
|
||||
|
||||
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
|
||||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
|
||||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件**
|
||||
如果值得记,也照实提取,并把 temporal 标成 one_off。`
|
||||
|
||||
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
|
||||
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
|
||||
// 默认值。不含 source_conversation:
|
||||
// 默认值;Temporal 则**一律归 unknown**(temporal.go 的 normalizeTemporal,
|
||||
// 绝不猜:猜成 one_off 的方向是把该留的记忆删掉)。不含 source_conversation:
|
||||
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
|
||||
type llmCandidate struct {
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
Kind string `json:"kind"`
|
||||
Temporal string `json:"temporal"`
|
||||
Tags []string `json:"tags"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ProjectID string `json:"project_id"`
|
||||
@@ -240,6 +262,10 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
|
||||
Content: content,
|
||||
Tags: c.Tags,
|
||||
Confidence: c.Confidence,
|
||||
// 时间性位(迁移 0027)在**产出侧**归一:模型只能选四个值之一,
|
||||
// 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错,
|
||||
// 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。
|
||||
Temporal: normalizeTemporal(c.Temporal),
|
||||
}
|
||||
if !it.Scope.valid() || !it.Kind.valid() {
|
||||
continue
|
||||
|
||||
@@ -97,7 +97,14 @@ const (
|
||||
// 规则。**规则内容与阈值未改**(同一份 N1~N7),但**判据的作用范围变了**:
|
||||
// 档位与 `expires_at` 现在对**每一行**在同一笔 INSERT 里落下,且不再依赖同步闸
|
||||
// 是否开启 —— 同一对条目在 d3.3 与 d3.4 下的落库形态因此可能不同,按规矩升版。
|
||||
const IngestRuleVersion = "d3.4"
|
||||
// - `d3.5`(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器在产出侧多
|
||||
// 标了一维时间性位(`one_off` / `recurring` / `durable` / `unknown`,迁移 0027),
|
||||
// 随同一笔 INSERT 落库。**判定的结论集与 d3.4 逐字相同**(`resolveRetention`
|
||||
// 不读这一位,见 retention.go 头注:复审裁决 D —— 两次候选接线都被生产重放否掉)。
|
||||
// 那为什么仍升版?因为**留痕面变了**:同一对条目在 d3.4 与 d3.5 下虽然结论相同,
|
||||
// 但 d3.5 的库里多了「模型当时标了什么」这一维可供事后归因;不升版,新旧留痕会
|
||||
// 混在同一版本号下,将来接线时无法区分「接线前的结论」与「接线后的结论」。
|
||||
const IngestRuleVersion = "d3.5"
|
||||
|
||||
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
|
||||
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
|
||||
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
|
||||
// 4. `IngestRuleVersion` 升到 `d3.4`(改判定规则必升版,否则新旧留痕无法区分);
|
||||
// 4. `IngestRuleVersion` 随判定变更升版(改判定规则必升版,否则新旧留痕无法区分;
|
||||
// 当前是 `d3.5`,M37 合入时是 `d3.4`);
|
||||
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
|
||||
|
||||
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
|
||||
@@ -107,8 +108,8 @@ func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
|
||||
}
|
||||
if rev.RuleVer != "d3.4" {
|
||||
t.Fatalf("闸留痕规则版本 = %q, want d3.4", rev.RuleVer)
|
||||
if rev.RuleVer != IngestRuleVersion {
|
||||
t.Fatalf("闸留痕规则版本 = %q, want %q(留痕版本必须与常量同源,不写死)", rev.RuleVer, IngestRuleVersion)
|
||||
}
|
||||
if s := r.Stats(); s.GateDeferred != 1 {
|
||||
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
|
||||
@@ -268,16 +269,22 @@ func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版
|
||||
// TestIngestRuleVersionIsD35 钉住任务书验收点④:改判定规则必升版
|
||||
// (design/23 §18.4),否则新旧留痕无法区分。
|
||||
//
|
||||
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
|
||||
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
|
||||
// 且不再依赖同步闸是否开启),因此按规矩升版。
|
||||
func TestIngestRuleVersionIsD34(t *testing.T) {
|
||||
//
|
||||
// d3.5(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器产出侧多标了一维
|
||||
// 时间性位(迁移 0027),随同一笔 INSERT 落库;`resolveRetention` 不读它,因此
|
||||
// 判定的结论集与 d3.4 逐字相同。仍升版是因为**留痕面变了**(库里多了「模型当时标了
|
||||
// 什么」这一维),将来接线时要能区分「接线前/后」的结论 —— 理由见 preinsert.go
|
||||
// 的版本史。
|
||||
func TestIngestRuleVersionIsD35(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IngestRuleVersion != "d3.4" {
|
||||
t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", IngestRuleVersion)
|
||||
if IngestRuleVersion != "d3.5" {
|
||||
t.Fatalf("IngestRuleVersion = %q, want d3.5(记录 temporal 信号,但不参与判定)", IngestRuleVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,41 @@ func migrateUp(t *testing.T, d *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// migrateDownToBefore 把库回滚到 id **之前**(即 id 与它之后已应用的每条都撤掉)。
|
||||
//
|
||||
// 步数从 `schema_migrations` 账本现算,而不是写死一个常数:写死的口径每加一条
|
||||
// 迁移就要改一次(0026 加进来时本文件的 0025 用例的 `MigrateDown(2)`、0027 加进来时
|
||||
// 0026 用例的 `MigrateDown(1)` 都是这么被打红的)—— 而「回滚到某条之前」这个
|
||||
// 语义本身与后面还压着几条无关。
|
||||
func migrateDownToBefore(t *testing.T, d *sql.DB, id string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
rows, err := d.QueryContext(ctx, `SELECT id FROM schema_migrations ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatalf("list applied migrations: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
steps := 0
|
||||
for rows.Next() {
|
||||
var got string
|
||||
if err := rows.Scan(&got); err != nil {
|
||||
t.Fatalf("scan applied migration: %v", err)
|
||||
}
|
||||
if got >= id {
|
||||
steps++
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate applied migrations: %v", err)
|
||||
}
|
||||
if steps == 0 {
|
||||
t.Fatalf("迁移 %s 不在账本里,无法回滚到它之前", id)
|
||||
}
|
||||
if err := db.MigrateDown(ctx, d, steps, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(%d) 回到 %s 之前: %v", steps, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// tableExists 报告表是否存在。
|
||||
func tableExists(t *testing.T, d *sql.DB, name string) bool {
|
||||
t.Helper()
|
||||
@@ -141,7 +176,6 @@ func countRawReviewRows(t *testing.T, d *sql.DB) int {
|
||||
// 索引、down 删表、再 up 能重建(回滚不留半状态)。
|
||||
func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
@@ -162,11 +196,10 @@ func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回滚:本表之上还压着一条 0026(`memory_retention_tier`,切片 M37),因此这里
|
||||
// 回滚**两条**才轮到 0025 —— 迁移账本按 id 升序、down 按降序撤,这是既有语义。
|
||||
if err := db.MigrateDown(ctx, d, 2, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(2): %v", err)
|
||||
}
|
||||
// 回滚:0025 之上还压着 0026(`memory_retention_tier`,切片 M37)与 0027
|
||||
// (`temporal`,切片 M45),因此要回到 0025 **之前**才轮得到它 —— 步数由
|
||||
// migrateDownToBefore 从账本现算,不写死常数(见该 helper 的注释)。
|
||||
migrateDownToBefore(t, d, ingestReviewMigrationID)
|
||||
if tableExists(t, d, ingestReviewTable) {
|
||||
t.Fatalf("回滚后 %s 应已被删除", ingestReviewTable)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,14 @@ type retentionVerdict struct {
|
||||
// 4. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
|
||||
// 5. 其余 ⇒ long(默认落点,fail-safe)。
|
||||
//
|
||||
// 🔴 **`temporal`(迁移 0027 的时间性元数据位)不在这个列表里**(r1 复审裁决 D,
|
||||
// 2026-10-27):这一位目前**只落库、只观测,不参与任何判定**。它被接进判定的两个
|
||||
// 候选设计都已被生产重放否掉 —— ①无条件 `one_off ⇒ temp` 会当天删掉 21 条长期约定;
|
||||
// ②只放开 N6 位置判据,最近 500 条里能翻的 13 条**逐条都是约定**、而复审点名的
|
||||
// 三条目标行 0/3 救得了(详见 temporal.go 头注)。因此判定的结论集与「没有这一位」
|
||||
// 逐字相同;temporal_test.go 有一条正断言钉住「喂 one_off 不影响任何结论」,
|
||||
// 另有一条变异靶子(把这一位重新接进本函数 ⇒ 那条正断言必红)。
|
||||
//
|
||||
// 🔴 「判第五态 `defer`」与「判 `retention_tier=temp`」**就是这一步**:闸与异步审查
|
||||
// 都只读 `item.RetentionTier`(preinsert_judge.go),不再各自跑一遍规则。M27/M37 的
|
||||
// 实测缺口正是「两处各判一遍、上游几乎不触发」,合并之后只有一个判据。
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**,
|
||||
@@ -69,10 +67,9 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
// 造出「既有部署」:把 0026 撤掉(列与索引消失),再按**旧形态**写入两行。
|
||||
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(1): %v", err)
|
||||
}
|
||||
// 造出「既有部署」:回到 0026 **之前**(列与索引消失),再按**旧形态**写入两行。
|
||||
// 步数由账本现算:0027(`temporal`,切片 M45)压在上面,写死 1 会撤错那条。
|
||||
migrateDownToBefore(t, d, retentionMigrationID)
|
||||
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("0026 回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
|
||||
}
|
||||
@@ -111,9 +108,7 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
|
||||
}
|
||||
|
||||
// 回滚:只撤结构,不改写任何一行的其它数据。
|
||||
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(1, 二次): %v", err)
|
||||
}
|
||||
migrateDownToBefore(t, d, retentionMigrationID)
|
||||
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
|
||||
}
|
||||
@@ -181,11 +176,18 @@ func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) {
|
||||
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols)
|
||||
}
|
||||
}
|
||||
// 自举 DDL 里新列写在最后一列(新建库的期望形状)——迁移路径上它后面可能还有
|
||||
// 自举追加的 project_id,因此只对自举侧断言顺序。
|
||||
// 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal`。
|
||||
// 迁移路径上这两列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。
|
||||
// (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后。)
|
||||
bootCols := tableColumns(t, bootstrapped, "memory_items")
|
||||
if last := bootCols[len(bootCols)-1]; !strings.Contains(last, "|"+retentionTierColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 最后一列 = %q, want %s", last, retentionTierColumn)
|
||||
if len(bootCols) < 2 {
|
||||
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols))
|
||||
}
|
||||
tail := bootCols[len(bootCols)-2:]
|
||||
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
|
||||
!strings.Contains(tail[1], "|"+temporalColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s",
|
||||
tail, retentionTierColumn, temporalColumn)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,21 @@ type MemoryItem struct {
|
||||
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
|
||||
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
|
||||
RetentionTier RetentionTier
|
||||
|
||||
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
|
||||
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与没做时间性
|
||||
// 判断的写入路径,口径见 temporal.go 头注)。
|
||||
//
|
||||
// 🔴 **它目前只观测、不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,
|
||||
// 判定的结论集与「没有这一位」逐字相同。保留它是为了积累「模型到底怎么标」的
|
||||
// 证据 —— 接线前必须先看那份分布(temporal.go 头注里给了查询)。
|
||||
//
|
||||
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
|
||||
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
|
||||
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列),
|
||||
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可;
|
||||
// 写入侧的校验/编码见 store_temporal.go。
|
||||
Temporal TemporalKind
|
||||
}
|
||||
|
||||
// 区分 memory 包各类失败的哨兵错误。每个都在
|
||||
@@ -246,6 +261,10 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if err := ensureRetentionSchema(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。
|
||||
if err := ensureTemporalSchema(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -284,6 +303,12 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
|
||||
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
|
||||
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
|
||||
//
|
||||
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
|
||||
// 落下(`temporal` 列)。**它只落库、不参与判定**(r1 复审裁决 D):`resolveRetention`
|
||||
// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**——
|
||||
// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线
|
||||
// 的唯一证据来源,见 temporal.go 头注的查询)。
|
||||
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
if err := item.normalize(); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -315,12 +340,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
|
||||
expires_at, retention_tier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
expires_at, retention_tier, temporal)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
|
||||
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention())
|
||||
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
|
||||
item.temporalColumn())
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
|
||||
}
|
||||
@@ -428,6 +454,9 @@ func (it *MemoryItem) validate() error {
|
||||
if !it.RetentionTier.valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
|
||||
}
|
||||
if err := it.validateTemporal(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS memory_items (
|
||||
occurred_at TEXT NOT NULL DEFAULT '',
|
||||
valid_to TEXT NOT NULL DEFAULT '',
|
||||
expires_at TEXT NOT NULL DEFAULT '',
|
||||
retention_tier TEXT NOT NULL DEFAULT ''
|
||||
retention_tier TEXT NOT NULL DEFAULT '',
|
||||
temporal TEXT NOT NULL DEFAULT ''
|
||||
)`
|
||||
|
||||
const createMemoryItemsPersonaIndex = `
|
||||
@@ -43,6 +44,11 @@ CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
|
||||
// (回填分型的入口)都吃它。列**必须**排在 CREATE TABLE 的最后:迁移的
|
||||
// `ALTER TABLE ADD COLUMN` 也把新列追加在末尾,两份 DDL 才逐列同形
|
||||
// (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。
|
||||
//
|
||||
// ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在
|
||||
// 它之后,两条路径的尾序都是 `… expires_at, retention_tier, temporal`。新列一律
|
||||
// 追加在末尾这条口径不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**,
|
||||
// 另外对尾序做一条显式断言)。
|
||||
const createMemoryItemsRetentionTierIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier
|
||||
ON memory_items (retention_tier)`
|
||||
@@ -0,0 +1,46 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 本文件是 `memory_items.temporal`(迁移 0027)在**写入路径**上的那一半:哨兵错误、
|
||||
// 字段校验、以及落库时的列值编码。
|
||||
//
|
||||
// 🔴 其余三处的归属(别在这条链上另开一份实现):
|
||||
// - **字段与语义**在 store.go 的 MemoryItem 上(结构体的字段不能跨文件声明),
|
||||
// 闭集与归一化在 temporal.go;
|
||||
// - **判定**仍是 `resolveRetention`(retention.go)里的 N1~N7 —— `temporal`
|
||||
// **只被记录,不参与判定**(r1 复审裁决 D),因此 `resolveRetention` 里
|
||||
// **没有、也不许有**「第 4 步看 temporal」这种东西;
|
||||
// - **DDL** 在 migrations/0027 + store_schema.go + temporal_schema.go。
|
||||
//
|
||||
// 要把 `temporal` 接进判定,前置条件写在 temporal.go 头注:先拿到模型真实标签分布
|
||||
// (那里给了两条只读 SQL),再改 `temporal_test.go` 的 temporalFieldSites 白名单 ——
|
||||
// 那张表是**有意的接线决定**的唯一入口,不许绕过它。
|
||||
//
|
||||
// 拆出本文件只为守 AGENTS §10 的文件行数上限(store.go 已到 500 行)与「模块按职责
|
||||
// 拆源文件」那条规范 —— 口径逐字未变。
|
||||
|
||||
// ErrInvalidTemporal 在条目的 Temporal(时间性元数据位,迁移 0027)不是闭集内的
|
||||
// 值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
|
||||
var ErrInvalidTemporal = errors.New("memory: invalid temporal kind")
|
||||
|
||||
// validateTemporal 只接受闭集内的值:时间性位是**枚举元数据**,一个自造的值
|
||||
// ("sometimes")既判不出档位、又不该静默落库 —— 提取器已经把模型输出归一成
|
||||
// unknown(temporal.go 的 normalizeTemporal),走到这里还是非法值只能是程序错误,
|
||||
// 因此直接拒,而不是替它挑一个值。
|
||||
func (it *MemoryItem) validateTemporal() error {
|
||||
if !it.Temporal.valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidTemporal, it.Temporal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// temporalColumn 返回要写进 `temporal` 列的值(与 `ingestGatePlan.retention()` /
|
||||
// `expiresAt()` 同族:把「条目 → 落库值」这一步收在类型自己身上,插入语句里不留
|
||||
// 裸转换,也就不可能出现两处各转一次、口径分叉)。
|
||||
func (it MemoryItem) temporalColumn() string {
|
||||
return string(it.Temporal)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package memory
|
||||
|
||||
import "strings"
|
||||
|
||||
// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它记录模型对
|
||||
// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」的判断。
|
||||
//
|
||||
// 当初为什么要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**:
|
||||
// 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词),
|
||||
// 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」
|
||||
//
|
||||
// 🔴 **当前状态:只观测,不参与判定**(r1 复审裁决 D,2026-10-27)。`resolveRetention`
|
||||
// **不读**这一位,判定的结论集与「没有这一位」逐字相同。保留它的唯一目的是**积累
|
||||
// 证据**:等真实提取跑出一批标签之后,再回答「模型到底怎么标」以及「接进判定是赚
|
||||
// 还是亏」。
|
||||
//
|
||||
// 为什么撤(两次候选接线都被生产重放否掉,别再凭直觉改回去):
|
||||
//
|
||||
// ① **无条件 `one_off ⇒ temp`**(任务书初版口径):最近 500 条 active 里人工按
|
||||
// 提取器口径标出的 23 条 `one_off`,其中 21 条是「约定 + 某一次没走成」形态 ⇒
|
||||
// 7 天后**把约定本身物理删掉**(不可撤回)。
|
||||
// ② **只放开 N6 的位置判据**(r1 复审给的改法):能翻的行在窗口内 13 条、**逐条都是
|
||||
// 约定**(`约定/约好 + 今天没走成`),而复审点名的三条目标行 **0/3** 救得了
|
||||
// (`…睡得比平时早` 被习惯词、`…以后不用再惦记` 被永久化标记、`自行车链条` 那条
|
||||
// 没有时点词)⇒ 收益为 0、风险集中在长期约定。
|
||||
//
|
||||
// 还有一层方法论上的理由:以上两轮数字**全部**建立在「对历史改写正文**人工**标注」
|
||||
// 上 —— 生产库里 `temporal` 列当时还不存在,模型从未在这批正文上产出过标签。
|
||||
// 用人工标签去决定一条「模型标签驱动」的接线,证据链是断的。
|
||||
//
|
||||
// 将来真要接线,前置条件是**这份数据**(不是又一次重放):
|
||||
//
|
||||
// SELECT temporal, COUNT(*) FROM memory_items GROUP BY temporal;
|
||||
// SELECT created_at, retention_tier, content FROM memory_items
|
||||
// WHERE temporal = 'one_off' ORDER BY created_at DESC LIMIT 10;
|
||||
//
|
||||
// 🔴 上面两条是**运维分析用的 SQL,不属于读侧代码路径**,别把它们抄进查询层:
|
||||
// timegate.go(以及任何读侧 SQL)明令**不许**对 `created_at` / `expires_at` 用
|
||||
// `datetime()` 之类的 SQLite 时间函数 —— 读侧闸是**定宽文本比较**,一旦有人按这两条
|
||||
// 的写法去写查询,时间格式一变就会静默判错(timegate_test.go 钉着那条反证)。
|
||||
// 这里出现 `datetime(created_at)` 只是因为「人工一次性分析、离线跑、不需要精确到
|
||||
// 纳秒」——**恰好**是读侧不许用的那个形态。
|
||||
//
|
||||
// 看 `one_off` 那 10 条里有多少是长期约定 —— 那才是「这一位能不能用」的判据。
|
||||
//
|
||||
// 闭集(与迁移 0027 的列值口径逐字一致):
|
||||
//
|
||||
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)
|
||||
// recurring 反复发生的规律(每天 / 每周 / 每次都…)
|
||||
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份
|
||||
// unknown 模型拿不准,或没给出合法值
|
||||
// '' 尚未分型:0027 之前的历史行 + 没做时间性判断的写入路径
|
||||
// (`ManualExtractor` 的「记住 X」、网关/管理面手写、整理侧回填 ——
|
||||
// 它们本来就由调用方显式给档位)
|
||||
//
|
||||
// 🔴 `unknown` 与 `”` 是两件事:`unknown` 是「问过模型了,它拿不准」;`”` 是
|
||||
// 「这条路径没问过」。把两者折成一个值会让「模型到底答了什么」这个观测面当场消失,
|
||||
// 而那正是保留这一位的全部意义。
|
||||
type TemporalKind string
|
||||
|
||||
const (
|
||||
// TemporalUnset 是「尚未分型」:历史行与没做时间性判断的写入路径(见文件头注)。
|
||||
// **它不是业务取值**,也**不是**「unknown」的同义词。
|
||||
TemporalUnset TemporalKind = ""
|
||||
|
||||
// TemporalOneOff 是一次性事件。它目前**不改变任何判定**:写入时只落库,
|
||||
// 供事后统计「模型把哪些行标成了某一次发生的事」。
|
||||
TemporalOneOff TemporalKind = "one_off"
|
||||
|
||||
// TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。
|
||||
TemporalRecurring TemporalKind = "recurring"
|
||||
|
||||
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。
|
||||
TemporalDurable TemporalKind = "durable"
|
||||
|
||||
// TemporalUnknown 是「问过模型了,它拿不准」。
|
||||
TemporalUnknown TemporalKind = "unknown"
|
||||
)
|
||||
|
||||
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是错误)。
|
||||
func (t TemporalKind) valid() bool {
|
||||
switch t {
|
||||
case TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// assigned 报告 t 是否**已由提取器分型**(即非空串)。它是「未分型」的唯一判据,
|
||||
// 与 RetentionTier.assigned 同族,调用方不许用 `t == TemporalUnknown` 之类的
|
||||
// 比较去替代它(那是「问过但拿不准」,不是「没问过」)。
|
||||
func (t TemporalKind) assigned() bool { return t != TemporalUnset }
|
||||
|
||||
// normalizeTemporal 把模型输出的自由文本归一到上面四个值。
|
||||
//
|
||||
// 🔴 **只做大小写折叠与首尾空白裁剪,绝不做同义词猜测**(「一次性」「once」→ 都不认):
|
||||
// 认不出来的答案就是解析失败,一律落 TemporalUnknown —— 这正是任务书
|
||||
// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。将来接线时
|
||||
// 这条尤其重要:猜同义词的代价是单向的(猜成 one_off 就删记忆)。
|
||||
func normalizeTemporal(s string) TemporalKind {
|
||||
switch TemporalKind(strings.ToLower(strings.TrimSpace(s))) {
|
||||
case TemporalOneOff:
|
||||
return TemporalOneOff
|
||||
case TemporalRecurring:
|
||||
return TemporalRecurring
|
||||
case TemporalDurable:
|
||||
return TemporalDurable
|
||||
default:
|
||||
// 空值、非法值、拼写错误、模型自造的新值:全部归 unknown(**不报错**)。
|
||||
// 一条候选记忆的价值不取决于它的时间性位是否写对,因此解析失败绝不
|
||||
// 让整批提取失败(那会静默丢掉同批的其他候选)。
|
||||
return TemporalUnknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件钉住**迁移 0027(`memory_temporal`)与 Store 自举 DDL 的同形与可回滚**,
|
||||
// 手法照 retention_migration_test.go 对 0026 的那一套(同一批 helpers)。
|
||||
//
|
||||
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
|
||||
// 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,temporal_schema.go)。
|
||||
// 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有:
|
||||
//
|
||||
// 1. TestMigration0027AddsColumnOverExistingRows —— 迁移落在**已有数据**上:行不丢、
|
||||
// 列补上、默认值是空串(未分型);down 真的把列删掉且**不改写任何一行的其它
|
||||
// 数据**(尤其是 `retention_tier` / `expires_at`,见 down 文件的口径);再 up 能重建。
|
||||
// 2. TestMigration0027ShapeMatchesStoreBootstrap —— 新列在两份 DDL 里**逐字同形**
|
||||
// (TEXT NOT NULL DEFAULT 空串),且自举侧尾序是 `… retention_tier, temporal`。
|
||||
|
||||
// temporalColumn 是被测列名(迁移与自举 DDL 里的字面量)。
|
||||
const temporalColumn = "temporal"
|
||||
|
||||
// temporalMigrationID 是 0027 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
|
||||
const temporalMigrationID = "0027_memory_temporal"
|
||||
|
||||
// TestMigration0027AddsColumnOverExistingRows 钉住迁移本体落在**既有数据**上的两条:
|
||||
// 行不丢、默认值是空串(未分型);以及再 up 后历史行回到「未分型」。
|
||||
func TestMigration0027AddsColumnOverExistingRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn)
|
||||
}
|
||||
|
||||
// 造出「0027 之前的既有部署」:回到 0027 之前,按旧形态写入两行。
|
||||
migrateDownToBefore(t, d, temporalMigrationID)
|
||||
if hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("0027 回滚后 %s 应已消失", temporalColumn)
|
||||
}
|
||||
insertLegacyMemoryRow(t, d, "legacy-1", "一条 0027 之前就存在的记忆")
|
||||
insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0027 之前就存在的记忆")
|
||||
|
||||
// 升级:0027 落在**已有数据**上。
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn)
|
||||
}
|
||||
var rows int
|
||||
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("迁移后行数 = %d, want 2(既有行一行都不能丢)", rows)
|
||||
}
|
||||
for _, id := range []string{"legacy-1", "legacy-2"} {
|
||||
var nat, content string
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT temporal, content FROM memory_items WHERE id = ?`, id).
|
||||
Scan(&nat, &content); err != nil {
|
||||
t.Fatalf("read %s: %v", id, err)
|
||||
}
|
||||
if nat != "" {
|
||||
t.Fatalf("既有行 %s 的时间性位 = %q, want 空串(历史行必须是「未分型」)", id, nat)
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatalf("既有行 %s 的正文丢了", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0027DownKeepsRowData 钉住 down 的**唯一**语义:只撤结构。判成 temp 的
|
||||
// 行在回滚之后档位与时效必须**一个字节都没变**(回滚改数据 = 把已判定的临时记忆升回
|
||||
// 永久,正是 down 文件里登记的那条反面)。
|
||||
func TestMigration0027DownKeepsRowData(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
if _, err := d.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, scope, kind, content, created_at, updated_at,
|
||||
retention_tier, expires_at, temporal)
|
||||
VALUES ('temp-1', 'persona.a', 'persona', 'relationship', '一条临时记忆',
|
||||
`+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'temp', ?, 'one_off')`,
|
||||
formatTime(time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC))); err != nil {
|
||||
t.Fatalf("seed temp row: %v", err)
|
||||
}
|
||||
|
||||
migrateDownToBefore(t, d, temporalMigrationID)
|
||||
if hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("回滚后 %s 应已消失", temporalColumn)
|
||||
}
|
||||
var tier, expires string
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT retention_tier, expires_at FROM memory_items WHERE id = 'temp-1'`).
|
||||
Scan(&tier, &expires); err != nil {
|
||||
t.Fatalf("read retention columns after down: %v", err)
|
||||
}
|
||||
if tier != string(RetentionTemp) || expires == "" {
|
||||
t.Fatalf("down 改写了档位/时效 = (%q, %q), want (%q, 非空) —— 回滚只撤结构",
|
||||
tier, expires, RetentionTemp)
|
||||
}
|
||||
|
||||
// 再 up:列以默认值重建,既有行回到「未分型」。
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("再次迁移后应重建 %s 列", temporalColumn)
|
||||
}
|
||||
var untyped int
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM memory_items WHERE temporal = ''`).Scan(&untyped); err != nil {
|
||||
t.Fatalf("count untyped: %v", err)
|
||||
}
|
||||
if untyped != 1 {
|
||||
t.Fatalf("重建后未分型行 = %d, want 1", untyped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0027ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的
|
||||
// memory_items(列集合)与自举 DDL 建出的必须一致,且新列在两份 DDL 里的形状
|
||||
// 逐字相同(TEXT NOT NULL DEFAULT 空串)。
|
||||
//
|
||||
// 列集合的等价性由 0026 的 TestMigration0026ShapeMatchesStoreBootstrap 一并覆盖
|
||||
// (它比对整表列集合);这里只钉本列自己的形状与自举侧的尾序。
|
||||
func TestMigration0027ShapeMatchesStoreBootstrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
migrated := openRawMemoryDB(t)
|
||||
migrateUp(t, migrated)
|
||||
|
||||
bootstrapped := openRawMemoryDB(t)
|
||||
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
|
||||
for name, d := range map[string]*sql.DB{"迁移": migrated, "自举": bootstrapped} {
|
||||
cols := sortedColumns(tableColumns(t, d, "memory_items"))
|
||||
if !slices.Contains(cols, temporalColumn+"|TEXT|1|''|0") {
|
||||
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, temporalColumn, cols)
|
||||
}
|
||||
}
|
||||
bootCols := tableColumns(t, bootstrapped, "memory_items")
|
||||
if len(bootCols) < 2 {
|
||||
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols))
|
||||
}
|
||||
tail := bootCols[len(bootCols)-2:]
|
||||
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
|
||||
!strings.Contains(tail[1], "|"+temporalColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s",
|
||||
tail, retentionTierColumn, temporalColumn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0027ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序**
|
||||
// (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0027 先建好列,随后 Store 的
|
||||
// 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store
|
||||
// 同样幂等,且写入路径可用(时间性位随同一笔 INSERT 落下)。
|
||||
func TestMigration0027ThenStoreBootstrapIsARegularPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
store, err := NewStore(ctx, d, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(列已由迁移建好): %v", err)
|
||||
}
|
||||
after := tableColumns(t, d, "memory_items")
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("自举之后 %s 列消失了", temporalColumn)
|
||||
}
|
||||
if _, err := NewStore(ctx, d, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore(第二次): %v", err)
|
||||
}
|
||||
if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) {
|
||||
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
|
||||
}
|
||||
|
||||
item := habitItem("主人有一条真实落库的观测样本", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("迁移建列后经 Store 落库的时间性位 = %q, want %q", nat, TemporalOneOff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是「时间性元数据位」轴(迁移 0027)的 schema 前滚:给**既有**的
|
||||
// memory_items 补 `temporal` 列。
|
||||
//
|
||||
// 与 retention_schema.go 同一手法、同一原因:`createMemoryItemsTable` 是
|
||||
// `CREATE TABLE IF NOT EXISTS`,只对**新库**生效 —— 既有部署的表早就建好了,
|
||||
// 新列不会自己长出来。记忆存储必须自包含、能在任意 *sql.DB 上使用,因此这条
|
||||
// 前滚不能只靠 `internal/db/migrations`。
|
||||
//
|
||||
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
|
||||
// `internal/db/migrations/0027_memory_temporal`;这里是「任意 *sql.DB」的前滚。
|
||||
// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里
|
||||
// 0027 先落地、这里走到 `duplicate column name` 是**预期路径**。
|
||||
//
|
||||
// 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入侧的判定输入 +
|
||||
// 事后巡检用的留痕列),提前建索引只是给每笔写入加一份没人用的维护成本。
|
||||
// 将来整理侧真要按它筛(例如「把 one_off 的行捞出来复核」)再加索引不迟,
|
||||
// 那时两条路径(迁移 + 本文件)一起加。
|
||||
|
||||
// ensureTemporalSchema 幂等地给 memory_items 补上时间性位列(列缺失时 ALTER,
|
||||
// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。
|
||||
//
|
||||
// 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(resolveRetention 对
|
||||
// 未分型的处理与 0027 之前完全一样,只跑 N1~N7)。本前滚刻意**不做**任何回填
|
||||
// 判定:历史行没有对应的提取器输出,拿正文反推「它当时是一次性事件吗」正是
|
||||
// 本片要消灭的那种猜测。
|
||||
func ensureTemporalSchema(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
|
||||
ADD COLUMN temporal TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("memory: roll forward temporal column: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。
|
||||
//
|
||||
// 🔴 当前契约(r1 复审裁决 D,2026-10-27):**这一位只观测,不参与判定。**
|
||||
// `resolveRetention` 不读它,判定的结论集与「没有这一位」逐字相同。因此本文件最重要
|
||||
// 的一条断言是**零影响**(TestTemporalBitDoesNotAffectRetention):同一段正文,
|
||||
// 无论 `temporal` 是 five 个取值里的哪一个,档位与规则码都必须完全相同。
|
||||
//
|
||||
// 变异靶子与之配套:谁把这一位重新接进 `resolveRetention`(例如先加一句
|
||||
// `if item.Temporal == TemporalOneOff { return temp }`),那条正断言必红 ——
|
||||
// 这不是「测试太严」,而是「接线前必须先有模型真实标签分布」这个裁决的守卫。
|
||||
|
||||
// temporalObservationCorpus 覆盖三类正文,每类都必须对 temporal 免疫:
|
||||
//
|
||||
// ① 旧规则判 long 的行(接线的「收益」候选);
|
||||
// ② 旧规则判 temp 的行(`durable` 也不能把它们救回来 —— 零影响是双向的);
|
||||
// ③ 受限来源 / 显式档位这类「更早的分支」(它们更不该被这一位碰到)。
|
||||
func temporalObservationCorpus() []string {
|
||||
return []string{
|
||||
"和主人有一个出门走十分钟的约定;今天没走成,明天要补上。",
|
||||
"主人有每天吃早餐的习惯,几乎不会省略早餐。",
|
||||
"今天下雨了",
|
||||
"谢谢啦",
|
||||
}
|
||||
}
|
||||
|
||||
// temporalAllKinds 是「业务上可能出现的全部取值」+ 未分型。
|
||||
func temporalAllKinds() []TemporalKind {
|
||||
return []TemporalKind{
|
||||
TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalBitDoesNotAffectRetention 是本文件的核心断言:**temporal 对留存判定零影响**
|
||||
// ——对同一段正文,五个取值的 `(档位, 规则码)` 必须逐字相同(且都以未分型为基准)。
|
||||
func TestTemporalBitDoesNotAffectRetention(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, content := range temporalObservationCorpus() {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(base): %v", err)
|
||||
}
|
||||
for _, temporal := range temporalAllKinds() {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
got, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(temporal=%q): %v", temporal, err)
|
||||
}
|
||||
if got != base {
|
||||
t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位必须零影响;"+
|
||||
"接线前必须先拿到模型真实标签分布,见 temporal.go 头注)",
|
||||
content, temporal, got, base)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotMakeAnythingTemporary 是上一条的正向版本,单独成例是为了
|
||||
// 让「接线会怎样」在失败信息里一眼可见:它只喂 `one_off`,并点名断言「旧规则判 long
|
||||
// 的行仍然 long」。
|
||||
func TestTemporalOneOffDoesNotMakeAnythingTemporary(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。"
|
||||
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(base): %v", err)
|
||||
}
|
||||
if base.Tier != RetentionLong {
|
||||
t.Fatalf("前提不成立:%q 旧规则判 %q(want long)", content, base.Tier)
|
||||
}
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(one_off): %v", err)
|
||||
}
|
||||
if got.Tier != RetentionLong || got.Rule != retentionNone {
|
||||
t.Fatalf("temporal=one_off 把 %q 判成 %+v —— 这一位不得参与判定(裁决 D)", content, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalIsStillRecorded 钉住「撤接线不等于撤观测」:`temporal` 仍随同一笔
|
||||
// INSERT 落库(迁移 0027 的列),而档位/有效期保持旧规则的结论 —— 这正是 D 要的
|
||||
// 数据来源:全库按 temporal 分组统计、抽样看 one_off 到底长什么样。
|
||||
func TestTemporalIsStillRecorded(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。"
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("temporal 列 = %q, want %q(观测面必须保留)", nat, TemporalOneOff)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong || expires != "" {
|
||||
t.Fatalf("落库档位/时效 = (%q, %q), want (long, 空) —— 观测不得改变判定",
|
||||
tier, expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDoesNotRescueRuleHitRows 钉住零影响是**双向**的:`durable` 曾经(M45
|
||||
// 初版)能把规则判 temp 的行救回 long;裁决 D 之后这条也取消 —— 判定只由 N1~N7 决定。
|
||||
func TestTemporalDoesNotRescueRuleHitRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
const content = "今天下雨了"
|
||||
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(base): %v", err)
|
||||
}
|
||||
if base.Tier != RetentionTemp {
|
||||
t.Fatalf("前提不成立:%q 旧规则判 %q(want temp)", content, base.Tier)
|
||||
}
|
||||
for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
got, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(%q): %v", temporal, err)
|
||||
}
|
||||
if got != base {
|
||||
t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位不得推翻规则)",
|
||||
content, temporal, got, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDoesNotTouchExplicitTierOrRestrictedSource 钉住「更早的分支」不被这一位
|
||||
// 影响:显式档位(人工入口)与受限来源(群聊/陌生人)都排在第 1/3 步。
|
||||
func TestTemporalDoesNotTouchExplicitTierOrRestrictedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
explicit := habitItem("谢谢啦", tidyTestTime(t), 0.9)
|
||||
explicit.RetentionTier = RetentionLong
|
||||
explicit.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, explicit)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(explicit long): %v", err)
|
||||
}
|
||||
if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionLong {
|
||||
t.Fatalf("显式 long + one_off 落库 = %q, want long(调用方显式档位不许被推翻)", tier)
|
||||
}
|
||||
|
||||
restricted := habitItem("今天下雨了", tidyTestTime(t), 0.9)
|
||||
restricted.Temporal = TemporalOneOff
|
||||
restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
|
||||
got, err = store.Add(ctx, restricted)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(restricted): %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTierUnset || expires != "" {
|
||||
t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires)
|
||||
}
|
||||
}
|
||||
|
||||
// readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读**
|
||||
// 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。
|
||||
func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
|
||||
t.Helper()
|
||||
var raw string
|
||||
if err := store.db.QueryRowContext(context.Background(),
|
||||
`SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil {
|
||||
t.Fatalf("read temporal column %s: %v", id, err)
|
||||
}
|
||||
return TemporalKind(raw)
|
||||
}
|
||||
|
||||
// temporalFieldSites 是**允许**读取 `.Temporal` 的全部生产代码位置(文件 + 行内片段)。
|
||||
//
|
||||
// 只有四类,缺一不可、多一不许(多出来的那条就是「接线」):
|
||||
//
|
||||
// ① 写入侧的字段校验(store_temporal.go 的 validateTemporal,两行);
|
||||
// ② 写入侧的列值编码(store_temporal.go 的 temporalColumn);
|
||||
// ③ 产出侧的构造(llmextractor.go 把模型输出归一后放进候选条目)。
|
||||
//
|
||||
// 为什么用「片段」而不是「文件」做粒度:按文件放行会让**同一文件里**新写的接线悄悄
|
||||
// 溜过去(例如在 store_temporal.go 里加一句 `if item.Temporal == …`)。按片段放行则
|
||||
// 任何新表达式都必须先改这张表 —— 而改这张表就是一次自觉的接线决定。
|
||||
func temporalFieldSites() []struct{ file, snippet string } {
|
||||
return []struct{ file, snippet string }{
|
||||
{"store_temporal.go", "it.Temporal.valid()"},
|
||||
{"store_temporal.go", "ErrInvalidTemporal, it.Temporal"},
|
||||
{"store_temporal.go", "string(it.Temporal)"},
|
||||
{"llmextractor.go", "normalizeTemporal(c.Temporal)"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalIsNotWiredIntoJudgement 是**源码级断言**:`temporal` 只许被记录,
|
||||
// **不许被判定读**。它回答的是「零影响」能不能被后来人无声破坏 ——
|
||||
// retention_test.go 的同类断言只钉住 `judgeExtractionRetention` 的调用点,管不到
|
||||
// 「在异步审查侧拿 `item.Temporal` 改判」这种接线方式。
|
||||
//
|
||||
// 复现的缺口(r1 复审变异 C 实测):在 `preinsert.go` 的 `applyVerdict` 入口插一句
|
||||
// `if item.Temporal == TemporalOneOff { …setIngestRetention(temp)… }`,**整个 memory
|
||||
// 包全绿** —— 那时没有任何用例察觉。异步侧恰恰是最容易被随手接线的地方(它拿到的
|
||||
// 是**已落库**的行),因此这条断言必须存在。
|
||||
//
|
||||
// 判据:扫生产代码(非 `_test.go`、非注释行)里所有含 `.Temporal` 的行,逐行要求
|
||||
// 命中 temporalFieldSites 的某一条;否则报**文件 + 行号 + 表达式片段**(不只报个数)。
|
||||
// 同时校验 allowlist 的每一条**确实命中过**,防止它老化成一张空头许可证。
|
||||
func TestTemporalIsNotWiredIntoJudgement(t *testing.T) {
|
||||
t.Parallel()
|
||||
files, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read package dir: %v", err)
|
||||
}
|
||||
allowed := temporalFieldSites()
|
||||
hit := make([]bool, len(allowed))
|
||||
var violations []string
|
||||
for _, f := range files {
|
||||
name := f.Name()
|
||||
if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") || !strings.Contains(trimmed, ".Temporal") {
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
for j, site := range allowed {
|
||||
if name == site.file && strings.Contains(trimmed, site.snippet) {
|
||||
hit[j] = true
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
violations = append(violations, fmt.Sprintf("%s:%d: %s", name, i+1, trimmed))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
t.Fatalf("temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)。"+
|
||||
"若这是有意的接线,先拿到模型真实标签分布并改 temporal.go 头注、再改本断言的 allowlist:\n%s",
|
||||
strings.Join(violations, "\n"))
|
||||
}
|
||||
for j, site := range allowed {
|
||||
if !hit[j] {
|
||||
t.Fatalf("allowlist 项 %s: %q 已不存在 —— 这张表老化了,请同步更新"+
|
||||
"(否则它只是一张空头许可证)", site.file, site.snippet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 /
|
||||
// 拼写变体)一律 unknown。
|
||||
//
|
||||
// 不做同义词映射是刻意的:将来接线时猜同义词的代价是单向的(猜成 one_off 就删记忆),
|
||||
// 而「拿不准一律 unknown」才是 fail-safe。
|
||||
func TestNormalizeTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
raw string
|
||||
want TemporalKind
|
||||
}{
|
||||
{"one_off", TemporalOneOff},
|
||||
{"ONE_OFF", TemporalOneOff},
|
||||
{" durable \n", TemporalDurable},
|
||||
{"recurring", TemporalRecurring},
|
||||
{"unknown", TemporalUnknown},
|
||||
{"", TemporalUnknown},
|
||||
{" ", TemporalUnknown},
|
||||
{"一次性", TemporalUnknown},
|
||||
{"one-off", TemporalUnknown},
|
||||
{"oneoff", TemporalUnknown},
|
||||
{"forever", TemporalUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := normalizeTemporal(tc.raw); got != tc.want {
|
||||
t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法
|
||||
// (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。
|
||||
func TestTemporalKindClosedSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ok := range temporalAllKinds() {
|
||||
if !ok.valid() {
|
||||
t.Fatalf("%q 应在闭集内", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} {
|
||||
if bad.valid() {
|
||||
t.Fatalf("%q 不应在闭集内", bad)
|
||||
}
|
||||
}
|
||||
if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false {
|
||||
t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)")
|
||||
}
|
||||
// 非法值必须被写入路径拒掉(不是静默落库成脏列值)。
|
||||
item := habitItem("一条正文", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalKind("sometimes")
|
||||
if err := item.normalize(); err == nil {
|
||||
t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMExtractorReadsTemporal 钉住产出侧:提取器**真的**在读模型给的这一位,
|
||||
// 且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。观测面的数据
|
||||
// 质量由这条用例守住。
|
||||
func TestLLMExtractorReadsTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[
|
||||
{"content":"候选一","temporal":"one_off"},
|
||||
{"content":"候选二","temporal":"durable"},
|
||||
{"content":"候选三","temporal":"Recurring"},
|
||||
{"content":"候选四","temporal":"forever"},
|
||||
{"content":"候选五"}
|
||||
]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "随便聊两句"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v(非法 temporal 绝不能让整批提取失败)", err)
|
||||
}
|
||||
want := map[string]TemporalKind{
|
||||
"候选一": TemporalOneOff,
|
||||
"候选二": TemporalDurable,
|
||||
"候选三": TemporalRecurring,
|
||||
"候选四": TemporalUnknown,
|
||||
"候选五": TemporalUnknown,
|
||||
}
|
||||
if len(items) != len(want) {
|
||||
t.Fatalf("候选数 = %d, want %d", len(items), len(want))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Temporal != want[it.Content] {
|
||||
t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的
|
||||
// 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了
|
||||
// 就会让每条候选都被归成 unknown,观测面等于白留)。
|
||||
func TestExtractionPromptCarriesTemporalEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} {
|
||||
if !strings.Contains(extractSystemPrompt, want) {
|
||||
t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// Anthropic Messages(/v1/messages)线协议常量。
|
||||
@@ -246,7 +247,7 @@ func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Respo
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
resp, err := a.doRequest(httpReq, false)
|
||||
if err != nil {
|
||||
@@ -303,7 +304,7 @@ func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEv
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
resp, err := a.doRequest(httpReq, true)
|
||||
if err != nil {
|
||||
@@ -398,7 +399,7 @@ func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*h
|
||||
}
|
||||
resp, err := a.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
|
||||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// OpenAI 兼容 /chat/completions 协议的线格式约束。
|
||||
@@ -134,7 +135,7 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
resp, err := a.doRequest(httpReq, false)
|
||||
if err != nil {
|
||||
@@ -171,7 +172,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
|
||||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
resp, err := a.doRequest(httpReq, true)
|
||||
if err != nil {
|
||||
@@ -226,7 +227,7 @@ func (a *OpenAIChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http
|
||||
}
|
||||
resp, err := a.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
|
||||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
@@ -24,10 +23,6 @@ import (
|
||||
// 错误体是攻击面,不是数据面。
|
||||
const errBodyCap = 512
|
||||
|
||||
// maxErrorTokenBytes 是 error.code / error.type 允许出镜的长度上限(见
|
||||
// safeErrorToken)。
|
||||
const maxErrorTokenBytes = 64
|
||||
|
||||
// upstreamLabel 报「这次调用实际打到哪」:优先主机(自建网关 / 代理下才看得出
|
||||
// 真实去向),取不到主机时退回 provider 名(代码里写死的短名)。两种形态都不含
|
||||
// 路径、查询串与凭据 —— 契约在 trace.HostOf。
|
||||
@@ -45,12 +40,21 @@ func upstreamLabel(name, baseURL string) string {
|
||||
// 而这类 error 会被 `chat run` / `loop step` 打日志。截断到 512 字节只是把
|
||||
// 「整段泄漏」变成「前 512 字节泄漏」,不是修复。
|
||||
//
|
||||
// error.code 与 error.type 的令牌守卫(trace.ErrorToken)**不在这里**:它已上移进
|
||||
// trace.UpstreamError 的构造函数(M49),上游报文里不像令牌的字段在那里就变空串。
|
||||
// 调用点再守一遍是 M46 留下的重复 —— 「同一件事守两遍」的下一步永远是两处口径分叉,
|
||||
// 所以这里删掉,只保留「构造即安全」这一处真源。
|
||||
//
|
||||
// 双 %w:既保持 errors.Is(err, ErrUpstream)(降级链按它分类,主 → 备 → 本地),
|
||||
// 也让 errors.Is(err, trace.ErrUpstream) 成立(调用方按上游失败分类)。
|
||||
func providerUpstreamError(name, baseURL string, resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
|
||||
info := trace.UpstreamError(upstreamLabel(name, baseURL), resp.StatusCode,
|
||||
resp.Header.Get("Content-Type"), body)
|
||||
info, ok := trace.UpstreamError(upstreamLabel(name, baseURL), resp.StatusCode,
|
||||
resp.Header.Get("Content-Type"), body).(*trace.UpstreamErrorInfo)
|
||||
if !ok {
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
|
||||
return fmt.Errorf("model: %w: status %d", ErrUpstream, resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
|
||||
}
|
||||
|
||||
@@ -59,34 +63,18 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error {
|
||||
//
|
||||
// evt.Message 是上游自由文本,Anthropic 会用它回显请求内容(例如「prompt is too
|
||||
// long: <片段>」),所以**整段丢弃**(原本是无上限拼进 error)。只保留 evt.Type
|
||||
// (协议枚举,如 overloaded_error)且必须像令牌才出镜:网关把散文塞进 type 时它
|
||||
// 是空串 —— 宁可少一条分类提示,不让自由文本借道 type 出去。
|
||||
// (协议枚举,如 overloaded_error)。
|
||||
//
|
||||
// 🔴 这里的类型是**流内事件**,报文不在 trace.UpstreamError 的视野里(HTTP 已 200,
|
||||
// body 为空),所以它属于「构造后注入」这一族,必须走 SetType(与构造函数同一道
|
||||
// 令牌守卫):网关把散文塞进 type 时它是空串 —— 宁可少一条分类提示,不让自由文本
|
||||
// 借道 type 出去。
|
||||
func streamEventError(upstream string, evt *anthropicStreamError) error {
|
||||
info, ok := trace.UpstreamError(upstream, 0, "text/event-stream", nil).(*trace.UpstreamErrorInfo)
|
||||
if !ok {
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
|
||||
return fmt.Errorf("model: %w: anthropic stream error event", ErrUpstream)
|
||||
}
|
||||
info.Type = safeErrorToken(evt.Type)
|
||||
info.SetType(evt.Type)
|
||||
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
|
||||
}
|
||||
|
||||
// safeErrorToken 只放行「像协议令牌」的短串(字母、数字与 `_ . -`),其余一律
|
||||
// 丢弃。error.code / error.type 在主流上游是枚举,但它们是**自由文本面**:网关、
|
||||
// 代理与兼容层可以往里面塞任何东西。丢弃它的代价只是少一条分类提示,保留一个
|
||||
// 不受控字段的代价是她的话进日志。
|
||||
func safeErrorToken(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len(s) > maxErrorTokenBytes {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '_' || r == '.' || r == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package model
|
||||
|
||||
// 本文件钉住 providerUpstreamError 的两条出镜边界:
|
||||
//
|
||||
// - **error.code / error.type 是自由文本面**:它们在主流上游是枚举,但网关、代理与
|
||||
// 兼容层可以往里面塞任何东西(包括回显的输入)。只有像令牌才出镜,其余变空串 ——
|
||||
// 而 status / 体积摘要这些诊断面不受影响。两个字段必须同一道守卫:同一份上游报文
|
||||
// 的两个出口(本文件与 streamEventError)口径不一致,就是一个漏点。
|
||||
// - **响应体正文一个字节都不进错误**:这是 providerUpstreamError 存在的理由
|
||||
// (旧实现把体截到 512B 拼进 error,而 `chat run` / `loop step` 会把它落日志)。
|
||||
//
|
||||
// 每条用例都是「把它放宽就变红」的形状:断言的是错误的文本,也就是真正进日志的字符串。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// upstreamErrResp 造一条非 2xx 响应:只带状态码、Content-Type 与体,够
|
||||
// providerUpstreamError 走完整条收敛路径。
|
||||
func upstreamErrResp(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderUpstreamErrorDropsFreeTextCode code 里是回显的用户原文 ⇒ 该字段整体消失
|
||||
// (连 `code=` 这个键都不出现),而 status / type 仍在。去掉令牌守卫就变红。
|
||||
func TestProviderUpstreamErrorDropsFreeTextCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const echoed = "Invalid value: '她刚才说的那句话'"
|
||||
body := `{"error":{"code":"` + echoed + `","type":"invalid_request_error"}}`
|
||||
|
||||
err := providerUpstreamError("openai", "https://api.example.com/v1/chat/completions", upstreamErrResp(http.StatusBadRequest, body))
|
||||
if err == nil {
|
||||
t.Fatal("非 2xx 却报成功")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, echoed) {
|
||||
t.Fatalf("code 里的用户原文进了错误(会随 chat run 落日志):%v", msg)
|
||||
}
|
||||
if strings.Contains(msg, "code=") {
|
||||
t.Fatalf("非令牌形状的 code 没有被丢弃:%v", msg)
|
||||
}
|
||||
// 诊断面不能一起丢:状态、协议枚举的 type、体积摘要都还在。
|
||||
for _, want := range []string{"status=400", "type=invalid_request_error", "body_bytes=", "api.example.com"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) || !errors.Is(err, trace.ErrUpstream) {
|
||||
t.Fatalf("降级链的哨兵断了:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderUpstreamErrorKeepsTokenShapedCode 真实枚举原样出镜:丢掉它只是少一条
|
||||
// 分类提示,但既然它像令牌就该留下。
|
||||
func TestProviderUpstreamErrorKeepsTokenShapedCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := `{"error":{"code":"Arrearage","type":"invalid_request_error"}}`
|
||||
|
||||
err := providerUpstreamError("deepseek", "https://api.deepseek.com/v1/chat/completions",
|
||||
upstreamErrResp(http.StatusPaymentRequired, body))
|
||||
if err == nil {
|
||||
t.Fatal("非 2xx 却报成功")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, "code=Arrearage") {
|
||||
t.Fatalf("令牌形状的 code 被误杀:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderUpstreamErrorDropsBodyText 体里除了 code/type 之外的字节一个都不进错误:
|
||||
// 上游把用户的提问回显在 error.message 里是真实写法。
|
||||
func TestProviderUpstreamErrorDropsBodyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prompt = "她的原始提问:帮我看看这段代码"
|
||||
body := `{"error":{"code":"invalid_request_error","type":"invalid_request_error",` +
|
||||
`"message":"Invalid value: '` + prompt + `'"}}`
|
||||
|
||||
err := providerUpstreamError("openai", "https://api.example.com/v1/chat/completions",
|
||||
upstreamErrResp(http.StatusBadRequest, body))
|
||||
if err == nil {
|
||||
t.Fatal("非 2xx 却报成功")
|
||||
}
|
||||
if strings.Contains(err.Error(), prompt) {
|
||||
t.Fatalf("error.message 的回显进了错误:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderUpstreamErrorDropsFreeTextType type 里是带空格标点的散文(网关 / 兼容层
|
||||
// 把回显塞进 type 是真实写法)⇒ 该字段整体消失(连 ` type=` 这个键都不出现),而同一
|
||||
// 份报文里的 status / code / body_bytes 仍在。去掉 type 的令牌守卫就变红 —— 兄弟出口
|
||||
// streamEventError 守的正是 type,两个出口对同一份上游报文必须口径一致。
|
||||
func TestProviderUpstreamErrorDropsFreeTextType(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: 她刚才说的那句话"
|
||||
body := `{"error":{"code":"invalid_request_error","type":"` + prose + `"}}`
|
||||
|
||||
err := providerUpstreamError("openai", "https://api.example.com/v1/chat/completions",
|
||||
upstreamErrResp(http.StatusBadRequest, body))
|
||||
if err == nil {
|
||||
t.Fatal("非 2xx 却报成功")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, prose) {
|
||||
t.Fatalf("type 里的回显进了错误(会随 chat run 落日志):%v", msg)
|
||||
}
|
||||
// ` type=` 带前导空格:content_type= 的尾巴里也有 "type=",不能用裸子串判。
|
||||
if strings.Contains(msg, " type=") {
|
||||
t.Fatalf("非令牌形状的 type 没有被丢弃:%v", msg)
|
||||
}
|
||||
// 诊断面不能一起丢:状态、令牌形状的 code、体积摘要都还在。
|
||||
for _, want := range []string{"status=400", "code=invalid_request_error", "body_bytes=", "api.example.com"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) || !errors.Is(err, trace.ErrUpstream) {
|
||||
t.Fatalf("降级链的哨兵断了:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package model
|
||||
|
||||
// 本文件是 M52 的出口回归探针:model 两个适配器里所有「把 net/http 的
|
||||
// *url.Error 直接打进错误串」的地方,一律先过 trace.URLCause。
|
||||
//
|
||||
// 为什么要测:`client.Do` 失败时 net/http 会包出
|
||||
// `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但
|
||||
// **用户名与整条 query(含 access_token / 预签名参数)原样在串里**,而这条串会进日志、
|
||||
// 进工单。`http.NewRequest` 的解析失败同样是 *url.Error,把整条 base URL 带出来。
|
||||
//
|
||||
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两个适配器 × 两条路(建请求 /
|
||||
// 发送)各一个子用例,不复用同一条断言链,因此变异能定位到具体那一处。
|
||||
// 同时钉住「只少地址、不少原因」:底层原因(connection refused)与哨兵 ErrUpstream
|
||||
// 都必须还在 —— 这批只许去掉地址,不许把排障信息一起删。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// urlCauseQuerySecret 是藏在 query 里的凭据。net/http **不会**打码 query,
|
||||
// 因此它是这批病害最真实的载体。
|
||||
urlCauseQuerySecret = "SECRET-2f8a1c"
|
||||
// urlCauseUser 是 userinfo 里的用户名(密码由 net/http 打码成 ***,
|
||||
// 因此用例只对用户名与 query 值断言 —— 那两段才是真正会漏的)。
|
||||
urlCauseUser = "svc-user"
|
||||
// urlCauseBadHost 里的空格让 url.Parse 失败 ⇒ 「建请求」那条路的入口。
|
||||
urlCauseBadHost = "bad host.example.com"
|
||||
)
|
||||
|
||||
// urlCauseBadBase 是「建请求」路径的 base URL:解析必然失败,且原文里同时带着
|
||||
// userinfo 与 query 凭据。
|
||||
func urlCauseBadBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
// urlCauseGoodBase 是「发送」路径的 base URL:能解析、能建请求,失败由 transport 造成。
|
||||
func urlCauseGoodBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@api.example.com/v1?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。
|
||||
type urlCauseFailTransport struct{}
|
||||
|
||||
func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
|
||||
}
|
||||
|
||||
// urlCauseFailClient 是一个只回传输错误的 client。
|
||||
func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} }
|
||||
|
||||
// assertNoURLCarried 断言错误串里既没有 query 凭据也没有用户名,但底层原因还在。
|
||||
func assertNoURLCarried(t *testing.T, err error, sentinel error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) {
|
||||
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoURLCarriedInBuild 是「建请求」路径的同口径断言:那一条路的底层原因是
|
||||
// url.Parse 的解析错误,因此存在性断言换成解析错误文本。
|
||||
func assertNoURLCarriedInBuild(t *testing.T, err error, sentinel error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIChatDropsURLFromTransportError 钉住 OpenAIChatAdapter.doRequest 那一处
|
||||
// (`client.Do` 失败)。
|
||||
func TestOpenAIChatDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewOpenAIChat(
|
||||
config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"},
|
||||
WithHTTPClient(urlCauseFailClient()),
|
||||
)
|
||||
_, err := ad.Chat(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
assertNoURLCarried(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
// TestOpenAIChatDropsURLFromBuildRequestError 钉住 OpenAIChatAdapter.Chat 里
|
||||
// NewRequest 失败那一处。
|
||||
func TestOpenAIChatDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"})
|
||||
_, err := ad.Chat(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
assertNoURLCarriedInBuild(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
// TestOpenAIChatStreamDropsURLFromBuildRequestError 钉住 ChatStream 里的同一个
|
||||
// 同族点(两条路各写了一次,改一处漏一处同样会漏地址)。
|
||||
func TestOpenAIChatStreamDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"})
|
||||
_, err := ad.ChatStream(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {})
|
||||
assertNoURLCarriedInBuild(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
// TestOpenAIChatStreamDropsURLFromTransportError 钉住 ChatStream 复用 doRequest 的那条路。
|
||||
func TestOpenAIChatStreamDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewOpenAIChat(
|
||||
config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"},
|
||||
WithHTTPClient(urlCauseFailClient()),
|
||||
)
|
||||
_, err := ad.ChatStream(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {})
|
||||
assertNoURLCarried(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
// TestAnthropicChatDropsURLFromTransportError 钉住 AnthropicChatAdapter.doRequest。
|
||||
func TestAnthropicChatDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewAnthropicChat(
|
||||
config.LLMConfig{AnthropicBaseURL: urlCauseGoodBase(), AnthropicModel: "m", AnthropicAPIKey: "k"},
|
||||
WithAnthropicHTTPClient(urlCauseFailClient()),
|
||||
)
|
||||
_, err := ad.Chat(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
assertNoURLCarried(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
// TestAnthropicChatDropsURLFromBuildRequestError 钉住 chatOnce 里的 NewRequest 失败。
|
||||
func TestAnthropicChatDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"})
|
||||
_, err := ad.Chat(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
|
||||
assertNoURLCarriedInBuild(t, err, ErrUpstream)
|
||||
}
|
||||
|
||||
// TestAnthropicChatStreamDropsURLFromBuildRequestError 钉住 streamOnce 里的同族点。
|
||||
func TestAnthropicChatStreamDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"})
|
||||
_, err := ad.ChatStream(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {})
|
||||
assertNoURLCarriedInBuild(t, err, ErrUpstream)
|
||||
}
|
||||
@@ -159,7 +159,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, err)
|
||||
return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.cfg.APIKey != "" {
|
||||
@@ -167,7 +167,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("vision: %w: %v", ErrUpstream, err)
|
||||
return "", fmt.Errorf("vision: %w: %v", ErrUpstream, trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -229,13 +229,3 @@ func blockedAddressOf(err error) (*blockedAddressError, bool) {
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// fetchCause 返回取回失败的底层原因:*url.Error 会把整条 URL(含查询串里
|
||||
// 可能的凭据)拼进错误串,这里只取内层错误,哨兵链因此也保留下来。
|
||||
func fetchCause(err error) error {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) && uerr.Err != nil {
|
||||
return uerr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 读取图像字节与响应体的体积上限,使恶意或
|
||||
@@ -117,7 +119,7 @@ func decodeBase64(src string) (string, []byte, error) {
|
||||
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
|
||||
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
|
||||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, sourceOrigin(rawURL))
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -139,7 +141,7 @@ func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string,
|
||||
if blocked, ok := blockedAddressOf(err); ok {
|
||||
return "", nil, blocked
|
||||
}
|
||||
return "", nil, fmt.Errorf("vision: %w: %w", ErrFetch, fetchCause(err))
|
||||
return "", nil, fmt.Errorf("vision: %w: %w", ErrFetch, trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -240,6 +242,19 @@ func fileExists(path string) bool {
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// sourceOrigin 给出可进错误串的「来源」形态:只有 scheme://host(见
|
||||
// trace.HostOf),路径、查询串与 userinfo 一律不出 —— 一个被拒的来源地址同样
|
||||
// 可能带签名 query,原样回显等于把它写进日志与工单。
|
||||
//
|
||||
// 解析不出主机(`file:///…`、相对路径、畸形串)时回占位符而**绝不回落原串**:
|
||||
// 回落就是泄漏,而空串会让错误文案失去「这次拒绝的是哪一类地址」的信息。
|
||||
func sourceOrigin(rawURL string) string {
|
||||
if origin := trace.HostOf(rawURL); origin != "" {
|
||||
return origin
|
||||
}
|
||||
return "<unknown source>"
|
||||
}
|
||||
|
||||
// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3
|
||||
// 所述的 HTTP client。这里每次调用返回一个新 client,
|
||||
// 而不是包级全局变量——后者为约束所禁止。
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package vision
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
|
||||
// 本适配器一行没改也应当自动安全 —— 视觉上游把散文塞进 error.code / error.type
|
||||
// 时,错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
|
||||
//
|
||||
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
|
||||
// 视觉这一处尤其不能漏 —— 送上游的图像可能是她的照片,回显它的散文同样不能进日志。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode 4xx 报文的 error.code /
|
||||
// error.type 里带散文 ⇒ 两个字段整体消失,status 与体积摘要仍在。
|
||||
func TestOpenAICompatibleAdapter_DropsFreeTextUpstreamCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
|
||||
|
||||
ad := newAdapter("https://vision.example.com")
|
||||
ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("vision call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes="} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("错误里缺少 %q:%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package vision
|
||||
|
||||
// 本文件是 M52 的出口回归探针:vision 适配器里「把 net/http 的 *url.Error 直接打进
|
||||
// 错误串」的两处(建请求 / 发送)一律先过 trace.URLCause。
|
||||
//
|
||||
// 病害形状:`client.Do` 失败时 net/http 包出
|
||||
// `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但用户名与
|
||||
// 整条 query(含凭据/签名)原样在串里;`http.NewRequest` 的解析失败则把整条 base URL
|
||||
// 带出来。这条串会进日志与工单。
|
||||
//
|
||||
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例,且都同时钉住
|
||||
// 「只少地址、不少原因」(底层原因与 ErrUpstream 哨兵必须还在)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
urlCauseQuerySecret = "SECRET-2f8a1c"
|
||||
urlCauseUser = "svc-user"
|
||||
urlCauseBadHost = "bad host.example.com"
|
||||
)
|
||||
|
||||
func visionURLCauseGoodBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@vision.example.com/v1?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
func visionURLCauseBadBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
// TestDescribeDropsURLFromTransportError 钉住 ask 里 `a.client.Do` 失败那一处。
|
||||
func TestDescribeDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := newAdapter(visionURLCauseGoodBase())
|
||||
ad.client = &http.Client{Transport: stubTransport(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
|
||||
})}
|
||||
|
||||
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) {
|
||||
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDescribeDropsURLFromBuildRequestError 钉住 ask 里 `http.NewRequest` 失败那一处。
|
||||
func TestDescribeDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := newAdapter(visionURLCauseBadBase())
|
||||
|
||||
_, err := ad.Describe(context.Background(), ImageRef{Source: SourceBase64, Data: "aGVsbG8="}, "")
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchURLUnsupportedSourceReportsOriginOnly 钉住 M55 的那处 URL 出口:
|
||||
// fetchURL 拒绝非 http(s) 来源时,错误串里只许有**来源**(scheme://host,
|
||||
// 见 trace.HostOf),不得带路径、查询串与 userinfo —— 被拒的地址同样可能带
|
||||
// 签名 query,而这条错误会进日志与工单。
|
||||
//
|
||||
// 变异:把 `sourceOrigin(rawURL)` 改回 `rawURL` ⇒ 本用例必红(secret、路径、
|
||||
// 用户名都会出现);把兜底改回空串 ⇒ 第三条用例失去 "<unknown source>"。
|
||||
func TestFetchURLUnsupportedSourceReportsOriginOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantOrigin string
|
||||
leaks []string
|
||||
}{
|
||||
{
|
||||
name: "带 query 的 ftp",
|
||||
in: "ftp://files.example.com/a/b.png?access_token=" + urlCauseQuerySecret,
|
||||
wantOrigin: "ftp://files.example.com",
|
||||
leaks: []string{urlCauseQuerySecret, "access_token", "/a/b.png"},
|
||||
},
|
||||
{
|
||||
name: "带 userinfo 的 ftp",
|
||||
in: "ftp://" + urlCauseUser + ":svc-pass@files.example.com/p.png",
|
||||
wantOrigin: "ftp://[redacted]@files.example.com",
|
||||
leaks: []string{urlCauseUser, "svc-pass", "/p.png"},
|
||||
},
|
||||
{
|
||||
name: "没有主机的 file",
|
||||
in: "file:///etc/passwd?x=1",
|
||||
wantOrigin: "<unknown source>",
|
||||
leaks: []string{"/etc/passwd", "x=1"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, _, err := fetchURL(context.Background(), c.in, nil)
|
||||
if !errors.Is(err, ErrUnsupportedSource) {
|
||||
t.Fatalf("err = %v, want ErrUnsupportedSource", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, leak := range c.leaks {
|
||||
if strings.Contains(msg, leak) {
|
||||
t.Fatalf("错误串带出了地址细节 %q:%v", leak, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, c.wantOrigin) {
|
||||
t.Fatalf("错误串没给出可读来源 %q:%v", c.wantOrigin, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// cloudMaxResponseBytes 限制读取云端语音响应的体积,使过大
|
||||
@@ -178,7 +179,7 @@ func (s *HTTPTTS) TTS(ctx context.Context, text string) (Audio, error) {
|
||||
func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payload []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
@@ -186,7 +187,7 @@ func postJSON(ctx context.Context, client *http.Client, apiKey, url string, payl
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
|
||||
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, trace.URLCause(err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 语音链云端一级的 DashScope(阿里百炼)STT/TTS 适配器。请求
|
||||
@@ -246,11 +247,11 @@ func (t *DashscopeTTS) audioBytes(ctx context.Context, body []byte) ([]byte, err
|
||||
func (t *DashscopeTTS) download(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err))
|
||||
}
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, err)
|
||||
return nil, fmt.Errorf("voice: %w: download: %v", ErrProviderFailed, trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -380,7 +381,7 @@ func firstNonEmpty(a, b string) string {
|
||||
func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string, payload []byte, sse string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, err)
|
||||
return nil, fmt.Errorf("voice: %w: build: %v", ErrProviderFailed, trace.URLCause(err))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if apiKey != "" {
|
||||
@@ -391,7 +392,7 @@ func dashscopePost(ctx context.Context, client *http.Client, apiKey, url string,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, err)
|
||||
return nil, fmt.Errorf("voice: %w: request: %v", ErrProviderFailed, trace.URLCause(err))
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@@ -18,10 +18,6 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maxErrorTokenBytes 是 error.code / request_id 允许出镜的长度上限(见
|
||||
// dashscopeErrorToken)。
|
||||
const maxErrorTokenBytes = 64
|
||||
|
||||
// classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 /
|
||||
// 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入
|
||||
// ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。
|
||||
@@ -45,8 +41,14 @@ func classifyDashscopeStatus(endpoint string, status int, contentType string, bo
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
|
||||
return fmt.Errorf("voice: %w", sentinel)
|
||||
}
|
||||
info.Code = dashscopeErrorToken(fields.code)
|
||||
if rid := dashscopeErrorToken(fields.requestID); rid != "" {
|
||||
// 🔴 DashScope 把错误码放在**顶层** code 上(不是 error.code),构造函数看不见它,
|
||||
// 因此这里属于「构造后注入」:走 SetCode(同一道令牌守卫)。这不是重复守卫 ——
|
||||
// 去掉了它,散文形态的顶层 code 就会直接出镜(TestDashscopeTTS_Non200DropsFreeTextCode
|
||||
// 会变红)。
|
||||
info.SetCode(fields.code)
|
||||
// request_id 不进 UpstreamErrorInfo,也不由构造函数解析:它是被**拼进错误文本**的
|
||||
// 第三个自由文本面,所以它的守卫只能留在这里(这不是「同一件事守两遍」)。
|
||||
if rid := trace.ErrorToken(fields.requestID); rid != "" {
|
||||
return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info)
|
||||
}
|
||||
return fmt.Errorf("voice: %w: %w", sentinel, info)
|
||||
@@ -102,35 +104,14 @@ func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields {
|
||||
// message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考
|
||||
// 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。
|
||||
// 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致);
|
||||
// 确实带回来的 code/type 也只以令牌形态出镜(过 dashscopeErrorToken)。
|
||||
// 确实带回来的 code/type 由 trace.UpstreamError **构造时**过令牌守卫(M49 上移),
|
||||
// 这里不再守第二遍。
|
||||
func dashscopeOKBodyError(endpoint string, body []byte) error {
|
||||
info, ok := trace.UpstreamError(trace.HostOf(endpoint), http.StatusOK, "", body).(*trace.UpstreamErrorInfo)
|
||||
if !ok {
|
||||
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断。
|
||||
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response", ErrProviderFailed)
|
||||
}
|
||||
info.Code = dashscopeErrorToken(info.Code)
|
||||
info.Type = dashscopeErrorToken(info.Type)
|
||||
return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w",
|
||||
ErrProviderFailed, info)
|
||||
}
|
||||
|
||||
// dashscopeErrorToken 只放行「像协议令牌」的短串(字母、数字与 `_ . -`):code
|
||||
// 在 DashScope 是枚举(InvalidParameter / Throttling.RateQuota / Arrearage …),
|
||||
// request_id 是十六进制/UUID 形状。两者都是自由文本面,不像令牌的一律丢弃 ——
|
||||
// 宁可少一条分类提示,不让散文借道出镜(同口径见 runtime/model 的 safeErrorToken)。
|
||||
func dashscopeErrorToken(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len(s) > maxErrorTokenBytes {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '_' || r == '.' || r == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package voice
|
||||
|
||||
// 本文件钉住 DashScope 错误收口里的**令牌守卫**(trace.ErrorToken):code 与
|
||||
// request_id 都是上游自由文本面 —— code 在 DashScope 是枚举(InvalidParameter /
|
||||
// Throttling.RateQuota / Arrearage …),但网关 / 兼容层可以往里塞任何东西。
|
||||
//
|
||||
// 已有的用例(TestDashscopeTTS_Non200DropsSpokenText)只断言「令牌形状的 code 被
|
||||
// 保留」,去掉守卫它照样通过 —— 那是个半张网。这里补上另一半:**不像令牌的 code
|
||||
// 必须变空串**,而 status / request_id 这些诊断面仍在。
|
||||
//
|
||||
// 形状是「把守去掉就变红」:断言的是错误文本,也就是真正进日志的字符串。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestDashscopeTTS_Non200DropsFreeTextCode 4xx 的 code 里放一段散文(带空格与标点)
|
||||
// ⇒ 该字段整体消失(连 `code=` 这个键都不出现),而 status 400 与 request_id 仍在。
|
||||
func TestDashscopeTTS_Non200DropsFreeTextCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"code":"` + prose + `","request_id":"req-0123456789abcdef"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
|
||||
_, err := tts.TTS(context.Background(), "随便说点什么")
|
||||
if err == nil {
|
||||
t.Fatal("400 却报成功")
|
||||
}
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed(熔断口径不变)", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, prose) {
|
||||
t.Fatalf("code 里的散文进了错误:%v", msg)
|
||||
}
|
||||
if strings.Contains(msg, "code=") {
|
||||
t.Fatalf("非令牌形状的 code 没有被丢弃:%v", msg)
|
||||
}
|
||||
// 诊断面不能一起丢:状态码与 request_id(对厂商报障时要给的那个)。
|
||||
for _, want := range []string{"status=400", "req-0123456789abcdef"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package voice
|
||||
|
||||
// 本文件是 M49 的**出口回归探针**:DashScope 的「HTTP 200 但业务失败」收口里,
|
||||
// error.code / error.type 的守卫已经上移进 trace.UpstreamError —— 本包那处调用点的
|
||||
// 重复守卫已删除,因此这里的断言直接钉在**构造函数**上(摘掉构造函数的守卫就变红)。
|
||||
//
|
||||
// 与它相邻的一条(TestDashscopeTTS_Non200DropsFreeTextCode)钉的是另一件事:
|
||||
// DashScope 的 4xx 把错误码放在**顶层** code(构造函数看不见),那一处靠
|
||||
// UpstreamErrorInfo.SetCode 注入,守卫仍在。两条合起来覆盖这一族的两个面。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDashscopeOKBodyError_DropsFreeTextErrorCode 200 报文的 error.code / error.type
|
||||
// 里带散文(TTS 场景下可能是她要念的台词被回显)⇒ 两个字段整体消失,而 status=200
|
||||
// 与 body_bytes(体积摘要)仍在。
|
||||
func TestDashscopeOKBodyError_DropsFreeTextErrorCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
|
||||
|
||||
err := dashscopeOKBodyError("https://dashscope.example.com/api/v1/services/audio/tts", body)
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed(熔断口径不变)", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "她刚才说的那句话") {
|
||||
t.Fatalf("上游 error.code/type 里的散文进了错误:%v", msg)
|
||||
}
|
||||
if strings.Contains(msg, " code=") || strings.Contains(msg, " type=") {
|
||||
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%v", msg)
|
||||
}
|
||||
for _, want := range []string{"status=200", "body_bytes="} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package voice
|
||||
|
||||
// 本文件是 M52 的出口回归探针:voice 内置的两条 HTTP 链(cloud 的 postJSON、
|
||||
// DashScope 的 dashscopePost / download)里「把 net/http 的 *url.Error 直接打进
|
||||
// 错误串」的 6 处,一律先过 trace.URLCause。
|
||||
//
|
||||
// 最要紧的是 DashScope 的 `download`:那是**预签名 OSS 地址**,签名与 STS 令牌就在
|
||||
// query 里。`client.Do` 失败时 net/http 会把整条地址渲染进错误串,于是「下载失败」
|
||||
// 这条最常见的报错就等于把一小时有效的签名写进日志。因此这一处单独给了用例。
|
||||
//
|
||||
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:6 处各一个用例,全部同时钉住
|
||||
// 「只少地址、不少原因」(底层原因与 ErrProviderFailed 哨兵必须还在)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
urlCauseQuerySecret = "SECRET-2f8a1c"
|
||||
urlCauseUser = "svc-user"
|
||||
urlCauseBadHost = "bad host.example.com"
|
||||
)
|
||||
|
||||
// urlCausePresigned 是一条**带签名**的 OSS 地址:签名、STS 令牌、AccessKeyId 全在
|
||||
// query 里。它们一旦进错误串/日志,就等于交付了一份可用的下载授权。
|
||||
const urlCausePresigned = "https://oss-cn-hangzhou.aliyuncs.com/out.wav" +
|
||||
"?Expires=1700000000&OSSAccessKeyId=AKIA-SENTINEL&Signature=SIG-SENTINEL-9f2c&security-token=STS-SENTINEL-4b1"
|
||||
|
||||
var (
|
||||
urlCausePresignSecrets = []string{"SIG-SENTINEL-9f2c", "STS-SENTINEL-4b1", "AKIA-SENTINEL"}
|
||||
)
|
||||
|
||||
func voiceURLCauseGoodBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@voice.example.com/v1?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
func voiceURLCauseBadBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。
|
||||
type urlCauseFailTransport struct{}
|
||||
|
||||
func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
|
||||
}
|
||||
|
||||
func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} }
|
||||
|
||||
// urlCauseStatusTransport 恒定回一个非 200 的响应(带一个可读的错误体)。
|
||||
func urlCauseStatusTransport(status int) *http.Client {
|
||||
return &http.Client{Transport: urlCauseRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"message":"denied"}`)),
|
||||
}, nil
|
||||
})}
|
||||
}
|
||||
|
||||
// urlCauseRoundTrip 把函数适配成 http.RoundTripper。
|
||||
type urlCauseRoundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f urlCauseRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// assertNoURLKept 断言错误串不含给定凭据、但含给定原因,且哨兵仍成立。
|
||||
func assertNoURLKept(t *testing.T, err error, secrets []string, reason string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
for _, s := range secrets {
|
||||
if strings.Contains(err.Error(), s) {
|
||||
t.Fatalf("错误串带出了凭据 %q:%v", s, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reason) {
|
||||
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed(分类/降级判据不许被带偏)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPTTSDropsURLFromTransportError 钉住 cloud 的 postJSON 里 `client.Do` 那一处。
|
||||
func TestHTTPTTSDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
tts := NewHTTPTTS(voiceURLCauseGoodBase(), "k", WithHTTPClient(urlCauseFailClient()))
|
||||
_, err := tts.TTS(context.Background(), "hi")
|
||||
assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "connection refused")
|
||||
if !errors.Is(err, ErrProviderFailed) {
|
||||
t.Fatalf("err = %v, want ErrProviderFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPSTTDropsURLFromBuildRequestError 钉住 cloud 的 postJSON 里 NewRequest 那一处。
|
||||
func TestHTTPSTTDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
stt := NewHTTPSTT(voiceURLCauseBadBase(), "k", WithHTTPClient(urlCauseFailClient()))
|
||||
_, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")})
|
||||
assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "invalid character")
|
||||
}
|
||||
|
||||
// TestDashscopeDownloadDropsPresignedURLFromTransportError 钉住 DashScope 的
|
||||
// `download`:预签名 OSS 地址在传输失败时**整条**不进错误串。
|
||||
func TestDashscopeDownloadDropsPresignedURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"})
|
||||
tts.client = urlCauseFailClient()
|
||||
|
||||
_, err := tts.download(context.Background(), urlCausePresigned)
|
||||
assertNoURLKept(t, err, urlCausePresignSecrets, "connection refused")
|
||||
// 地址的 host 段同样不该出现:这条串会进日志,而"哪个 OSS 域名"本身不是排障必需,
|
||||
// 真正的排障线索(连接被拒 / 超时)与 ErrProviderFailed 分类都还在。
|
||||
if strings.Contains(err.Error(), "oss-cn-hangzhou") {
|
||||
t.Fatalf("错误串带出了预签名地址的 host:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopeDownloadDropsPresignedURLFromBuildRequestError 钉住 `download` 里
|
||||
// NewRequest 那一处(畸形地址仍会回 *url.Error,同样是整条地址)。
|
||||
func TestDashscopeDownloadDropsPresignedURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"})
|
||||
tts.client = urlCauseFailClient()
|
||||
|
||||
_, err := tts.download(context.Background(), "https://"+urlCauseUser+":svc-pass@"+urlCauseBadHost+"/out.wav?Signature=SIG-SENTINEL-9f2c")
|
||||
assertNoURLKept(t, err, []string{"SIG-SENTINEL-9f2c"}, "invalid character")
|
||||
}
|
||||
|
||||
// TestDashscopeDownloadKeepsStatus 钉住「只少地址、不少状态」:被拒时状态码仍在错误串里。
|
||||
func TestDashscopeDownloadKeepsStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeAPIKey: "k"})
|
||||
tts.client = urlCauseStatusTransport(http.StatusForbidden)
|
||||
|
||||
_, err := tts.download(context.Background(), urlCausePresigned)
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("错误串丢了状态码(排障会变盲):%v", err)
|
||||
}
|
||||
for _, s := range urlCausePresignSecrets {
|
||||
if strings.Contains(err.Error(), s) {
|
||||
t.Fatalf("错误串带出了凭据 %q:%v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDashscopePostDropsURLFromTransportError 钉住 dashscopePost 里 `client.Do` 那一处。
|
||||
func TestDashscopePostDropsURLFromTransportError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dashscopePost(context.Background(), urlCauseFailClient(), "k", voiceURLCauseGoodBase(), []byte(`{}`), "")
|
||||
assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "connection refused")
|
||||
}
|
||||
|
||||
// TestDashscopePostDropsURLFromBuildRequestError 钉住 dashscopePost 里 NewRequest 那一处。
|
||||
func TestDashscopePostDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := dashscopePost(context.Background(), urlCauseFailClient(), "k", voiceURLCauseBadBase(), []byte(`{}`), "")
|
||||
assertNoURLKept(t, err, []string{urlCauseQuerySecret}, "invalid character")
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件是 design/11 §4「敏感操作代审」的**实质判定**侧(主人 2026-10-05 裁决的口径 A):
|
||||
@@ -28,8 +30,9 @@ const judgeTimeout = 5 * time.Second
|
||||
// 不需要整段派发文本(成本受控 + 降低把长文本搬进判定问句的机会)。
|
||||
const maxTaskRunes = 1000
|
||||
|
||||
// maxReasonRunes 是判定理由可进入审计/前端小字的字符上限(同样是为了成本与
|
||||
// 不把长文本搬进呈现层)。超出按 rune 截断。
|
||||
// maxReasonRunes 是判定理由**进入清洗前**的字符上限:理由是模型自由文本,先按
|
||||
// rune 截断,避免在无界文本上跑凭据匹配(成本受控)。最终出日志/小字的长度由
|
||||
// trace.RedactText 的 80 rune 档决定——两处都拦,短的那档生效。
|
||||
const maxReasonRunes = 200
|
||||
|
||||
// 放行被拒时的理由口径。判不出来(未装配判定器 / 调用失败 / 超时 / 输出不可解析)
|
||||
@@ -176,9 +179,13 @@ func (a *Approver) logf(msg string, ap *Approval, err error) {
|
||||
a.log.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
// cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白,
|
||||
// 截到 maxReasonRunes。全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到
|
||||
// 一条理由)。
|
||||
// cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白、
|
||||
// 截到 maxReasonRunes,最后过 trace.RedactText(80 rune 档)。
|
||||
//
|
||||
// 打码这一步是**必须**的:理由是**模型自由文本**,判定器完全可能把它在参数摘要里
|
||||
// 看到的凭据原样抄进理由(「不该把 Authorization: Bearer … 发出去」),而这条理由
|
||||
// 要进审计日志与前端小字两个读面。它与其余出图文本走同一套清洗,绝不原样落日志。
|
||||
// 全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到一条理由)。
|
||||
func cleanReason(reason string) string {
|
||||
var b strings.Builder
|
||||
space := false
|
||||
@@ -198,7 +205,7 @@ func cleanReason(reason string) string {
|
||||
if out == "" {
|
||||
return reasonOutOfScope
|
||||
}
|
||||
return clip(out, maxReasonRunes)
|
||||
return trace.RedactText(clip(out, maxReasonRunes))
|
||||
}
|
||||
|
||||
// clip 把 s 截到至多 max 个 rune,超出时追加省略号(max 非正时原样返回)。
|
||||
|
||||
@@ -320,6 +320,44 @@ func TestReviewAndApprove_WritesJudgementReasonToAuditLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_ReasonIsRedactedBeforeLogging 钉住「判定理由出日志前打码」:理由是
|
||||
// **模型自由文本**,判定器完全可能把它在参数摘要里看到的凭据原样抄进理由,而这条
|
||||
// 理由要进审计日志与前端小字两个读面(M54)。
|
||||
//
|
||||
// 🔴 断言打在**日志输出字节**上(真 slog handler → 缓冲区):删掉 cleanReason 里的
|
||||
// trace.RedactText,raw 凭据就会原样出现在审计行里,本用例随即变红。
|
||||
func TestJudgeCall_ReasonIsRedactedBeforeLogging(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const rawSecret = "sk-live-9f8e7d6c5b4a3210"
|
||||
var buf bytes.Buffer
|
||||
ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}),
|
||||
WithSubagentOrigin(subagentOrigin("整理导出文件")),
|
||||
WithCallJudge(&stubJudge{verdict: JudgeVerdict{
|
||||
Allowed: false,
|
||||
Reason: "越界:任务不需要带着 api_key=" + rawSecret + " 去请求上游",
|
||||
}}),
|
||||
)
|
||||
got, err := ap.ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal curl -H 'x'", "curl")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
// 日志面是主断言:审计行里只能出现打码后的形态。
|
||||
out := buf.String()
|
||||
if strings.Contains(out, rawSecret) {
|
||||
t.Fatalf("审计日志里出现了原样凭据: %s", out)
|
||||
}
|
||||
for _, want := range []string{`"reason":`, "[redacted]", "越界:任务不需要带着"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("审计日志缺少 %q(打码不能把理由打没): %s", want, out)
|
||||
}
|
||||
}
|
||||
// 同一条理由也进前端小字(Approval.Reason),因此读面之外的返回值同样不许带原文。
|
||||
if strings.Contains(got.Reason, rawSecret) {
|
||||
t.Fatalf("Approval.Reason 里带着原样凭据(会进前端小字): %q", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewApprover_WarnsWhenJudgeHasNoOrigin:接线错误(装配了判定器却没有来源解析 ⇒
|
||||
// 判定永不触发)必须显式告警,不能静默空转。
|
||||
func TestNewApprover_WarnsWhenJudgeHasNoOrigin(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package trace
|
||||
|
||||
import "strings"
|
||||
|
||||
// 本文件是「上游错误里的某个字段能不能出镜」的守卫之一:只放行「像协议枚举」的短串。
|
||||
//
|
||||
// 背景:UpstreamErrorInfo 只解析 error.code / error.type 两个字段,而它们是**自由
|
||||
// 文本面** —— 主流上游里它们确实是枚举(invalid_request_error、overloaded_error、
|
||||
// Throttling.RateQuota …),但网关、代理与兼容层可以往里面塞任何东西,包括回显的
|
||||
// 用户原文。因此这两个字段出镜前必须像令牌:短、只含标识符字符。
|
||||
//
|
||||
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。
|
||||
//
|
||||
// 🔴 M49 收口的是**应用点**,不是调用权:UpstreamError 构造时对 error.code /
|
||||
// error.type 各守一次(构造即安全),构造后的注入走 SetCode / SetType(同一道守
|
||||
// 卫)—— 这样一来「新调用点默认安全」,不必也不该在 model/voice 里各抄一份。
|
||||
//
|
||||
// 🔴 M51 纠正口径(2026-10-26,复审 p2 裁定):本文件旧注释写的是「包外不应出现
|
||||
// ErrorToken 调用」。那句话把 M48 的真实教训(**调用方逐点补守卫**会漏)错译成了
|
||||
// 「只有本包能守」,于是把「按**字段类别**判定」这条正确口径一并否掉,而现实里包外
|
||||
// 一直有正当调用。正确口径按字段类别分:
|
||||
//
|
||||
// - **协议枚举字段** —— error.code / error.type、CDP `error.message` 里的协议错误
|
||||
// 名、各类状态码:值域是协议定义的短标识符 ⇒ 用 ErrorToken 守。像枚举就保留,
|
||||
// 散文(带空格、标点、超长)一律丢弃;
|
||||
// - **自由文本字段** —— error.message、模型/对端「说的话」、页面异常文本:**默认
|
||||
// 不带**;确实要带时按「带上限地带」处理(rune 上限 + RedactText,见
|
||||
// channel/adapter 的 reviewExcerpt、services/channelbridge 的 judgeReason、trace.UpstreamError 的 WithUpstreamMessage 与 httpclient 的 WithEnvelopeMessage),
|
||||
// 绝不拿 ErrorToken 当清洗(它只会把整段话删掉,不是清洗)。
|
||||
//
|
||||
// 包外调用的现存例子(都是枚举口径,属正当用法):
|
||||
// - `internal/capability/browser/cdp.go` 的 cdpError.Error():CDP error.message
|
||||
// 只有像协议枚举才出镜,其余只留字节数(M51);
|
||||
// - `internal/runtime/multimodal/voice/dashscope_error.go`:DashScope 的 code/type
|
||||
// (M46 起就在包外调用)。
|
||||
//
|
||||
// 因此现在的约束只有一条:**调用前先确认那个字段是枚举口径**;自由文本字段不许用
|
||||
// 它冒充清洗,也不许为了「新调用点安全」而在本文件之外复制一份守卫实现 —— 守卫本身
|
||||
// 仍然只有这一份。
|
||||
|
||||
// maxErrorTokenBytes 是 error.code / error.type / request_id 允许出镜的长度上限。
|
||||
// 64 字节足够容纳任何真实的枚举与 UUID 形状,又远小于「一段话」。
|
||||
const maxErrorTokenBytes = 64
|
||||
|
||||
// ErrorToken 只放行「像协议令牌」的短串(字母、数字与 `_ . -`),其余一律丢弃并返回
|
||||
// 空串 —— 包括带空格、标点、引号的散文,以及超长串。调用方据此决定「这个字段出不
|
||||
// 出镜」,而不是先出镜再清洗。
|
||||
func ErrorToken(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len(s) > maxErrorTokenBytes {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case r == '_' || r == '.' || r == '-':
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package trace
|
||||
|
||||
// 本文件钉住 ErrorToken 的唯一职责:error.code / error.type 这类**自由文本面**只有
|
||||
// 「像协议令牌」才允许出镜。它们在上游里是枚举,但网关、代理与兼容层可以往里面塞任何
|
||||
// 东西 —— 包括回显的用户原文。
|
||||
//
|
||||
// 每条用例都是「把它放宽就变红」的形状。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestErrorTokenKeepsProtocolTokens 真实枚举必须原样通过:丢掉它们的代价是少一条
|
||||
// 分类提示(重试 / 降级 / 熔断的口径)。
|
||||
func TestErrorTokenKeepsProtocolTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, token := range []string{
|
||||
"invalid_request_error",
|
||||
"overloaded_error",
|
||||
"Throttling.RateQuota",
|
||||
"rate-limit",
|
||||
"429",
|
||||
" sparse_error ",
|
||||
} {
|
||||
if got := ErrorToken(token); got != strings.TrimSpace(token) {
|
||||
t.Fatalf("ErrorToken(%q) = %q, want 令牌原值(去首尾空白)", token, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorTokenDropsFreeText 只有像令牌才出镜:带空格、标点、引号的散文一律空串 ——
|
||||
// 那正是「上游把用户原文回显进 code」的形态。
|
||||
func TestErrorTokenDropsFreeText(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, text := range []string{
|
||||
"Invalid value: '她刚才说的那句话'",
|
||||
"prompt is too long",
|
||||
"```json\n{\"a\":1}\n```",
|
||||
"bad,code",
|
||||
"code=1",
|
||||
"",
|
||||
" ",
|
||||
"line\nbreak",
|
||||
"emoji🙂",
|
||||
} {
|
||||
if got := ErrorToken(text); got != "" {
|
||||
t.Fatalf("ErrorToken(%q) = %q, want 空串", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorTokenRejectsOverlong 超长串不是令牌(边界 64 字节),否则「话」可以靠长度
|
||||
// 混进来。
|
||||
func TestErrorTokenRejectsOverlong(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := ErrorToken(strings.Repeat("a", 64)); got == "" {
|
||||
t.Fatal("64 字节的令牌被误杀(边界应含)")
|
||||
}
|
||||
if got := ErrorToken(strings.Repeat("a", 65)); got != "" {
|
||||
t.Fatalf("超长串被放行:%q", got)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,27 @@ func IsSecretKey(key string) bool {
|
||||
// to maxSummaryRunes, so a published string can never carry a secret or an
|
||||
// unbounded blob.
|
||||
func RedactText(s string) string {
|
||||
return truncate(redactSecrets(s), maxSummaryRunes)
|
||||
return RedactTextLimited(s, maxSummaryRunes)
|
||||
}
|
||||
|
||||
// RedactTextLimited 是 RedactText 的**带上限**形态:同一套凭据形状清洗,截断长度
|
||||
// 由调用方给。它存在的理由是两个读面要的不是同一种东西 ——
|
||||
//
|
||||
// - **出图 / 摘要面**(span props、事件正文、工具结果摘要)要的是摘要 ⇒
|
||||
// `maxSummaryRunes`(80)足够,且保证一屏可读。这些调用方继续用 `RedactText`;
|
||||
// - **日志面**(`GET /admin/observability/logs` 的一行)要的是**可排障的全文** ⇒
|
||||
// 80 会把长错误行拦腰截断,等于丢掉诊断信息。
|
||||
//
|
||||
// 但它绝不接受「无上限散用」:一条日志可能是 `%v` 出来的 MiB 级对象,原样塞进
|
||||
// JSON 响应会撑爆面板的帧与响应体。因此上限**必须由调用方显式给出**并写清理由
|
||||
// (网关侧见 `maxLogMsgRunes`)。
|
||||
//
|
||||
// limit <= 0 表示**不截断**(只打码),留给「体积已由自己界定」的调用方。
|
||||
//
|
||||
// 🔴 清洗逻辑仍然只有一份(redactSecrets):上限是参数,不是第二条脱敏路径 ——
|
||||
// 另写一套必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。
|
||||
func RedactTextLimited(s string, limit int) string {
|
||||
return truncate(redactSecrets(s), limit)
|
||||
}
|
||||
|
||||
// RedactEvent returns ev with every string value — including those nested in maps
|
||||
|
||||
@@ -18,6 +18,12 @@ import (
|
||||
// JSON 里的 error.code / error.type;**error.message 默认不带**,只有显式
|
||||
// opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到
|
||||
// maxUpstreamMessageRunes;
|
||||
// - 🔴 **code / type 构造时就过令牌守卫**(ErrorToken):只有像协议令牌的短串才
|
||||
// 出镜,散文一律变空串。守卫**在本构造函数里**,不在调用点 —— 因此任何新调用点
|
||||
// **默认就是安全的**。M46 曾把它加在调用方(model / voice 各一份),M48 复审的
|
||||
// 中危 finding 证明那正是漂移形态:调用方逐点补,四条出口(httpclient / vision /
|
||||
// embedding / weixin)就一直漏。M49 把守卫上移到这里,并把「构造后再注入」这一
|
||||
// 条窄路收进 SetCode / SetType(同样守卫)。
|
||||
// - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的
|
||||
// param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志;
|
||||
// - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段,
|
||||
@@ -63,9 +69,12 @@ type UpstreamErrorInfo struct {
|
||||
// BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能
|
||||
// 认出来,正文拿不到)。
|
||||
BodySHA256 string
|
||||
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染)。
|
||||
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染),**构造时已过
|
||||
// 令牌守卫**(与 SetCode 同一道):不像协议令牌的值在这里恒为空串。空串是预期
|
||||
// 结果 —— 宁可不显示一条分类提示,也不让正文借道字段出镜;调用方不得按「是否为空」
|
||||
// 推断「上游没给码」(两者在构造后不可区分)。
|
||||
Code string
|
||||
// Type 是上游 JSON 里 error.type。
|
||||
// Type 是上游 JSON 里 error.type,**构造时已过令牌守卫**,口径与 Code 逐字相同。
|
||||
Type string
|
||||
// Message 是上游 JSON 里 error.message,**只有显式 opt-in(WithUpstreamMessage)
|
||||
// 才非空**:那一刻它已压平空白、清洗凭据形状并截断到
|
||||
@@ -182,6 +191,12 @@ func WithUpstreamMessage() UpstreamOption {
|
||||
// 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256
|
||||
// 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点
|
||||
// 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。
|
||||
//
|
||||
// 🔴 同样默认的还有 **code / type 的令牌守卫**:它们不是「可选加固」,而是本构造函数
|
||||
// 的固定行为 —— 报文里的 error.code / error.type 只有像协议令牌(≤64 字节、仅
|
||||
// [A-Za-z0-9_.-])才出镜,其余变空串。调用方**不需要**、也**不要**再守一遍:守卫
|
||||
// 加在调用点意味着新调用点默认不安全(M46 → M49 的教训)。构造后要注入这两个字段
|
||||
// 时走 SetCode / SetType,别直接赋值。
|
||||
func UpstreamError(upstream string, status int, contentType string, body []byte, opts ...UpstreamOption) error {
|
||||
var cfg upstreamOptions
|
||||
for _, opt := range opts {
|
||||
@@ -199,8 +214,10 @@ func UpstreamError(upstream string, status int, contentType string, body []byte,
|
||||
info.BodySHA256 = DigestOf(string(body)).SHA256
|
||||
}
|
||||
if parsed, ok := parseUpstreamErrorBody(body); ok {
|
||||
info.Code = parsed.Code
|
||||
info.Type = parsed.Type
|
||||
// 🔴 构造即安全(M49):code / type 在这里过令牌守卫,调用点不需要也不应该
|
||||
// 再守一遍 —— 守卫留在调用方的形态必然漏点(新调用点默认不安全)。
|
||||
info.Code = ErrorToken(parsed.Code)
|
||||
info.Type = ErrorToken(parsed.Type)
|
||||
if cfg.keepMessage {
|
||||
info.Message = sanitizeUpstreamMessage(parsed.Message)
|
||||
}
|
||||
@@ -208,6 +225,21 @@ func UpstreamError(upstream string, status int, contentType string, body []byte,
|
||||
return info
|
||||
}
|
||||
|
||||
// SetCode 是**构造之后**改写 Code 的唯一合法通道:它过的是与构造函数同一道令牌守卫
|
||||
// (ErrorToken),所以「写入即安全」。
|
||||
//
|
||||
// 为什么还需要这条窄路:有些上游把错误码放在**顶层** code(DashScope 的 4xx 报文)、
|
||||
// 或者把失败放进一个已经 200 的**流内事件**里(Anthropic SSE 的 error.type)——
|
||||
// 这两种报文都不在 UpstreamError 参数的视野里,只能构造后注入。注入点也是守卫点,
|
||||
// 但守卫代码留在本包:调用点只写 `info.SetCode(raw)`,不再各抄一份 ErrorToken
|
||||
// (M46 的两份实现就是这么漂移出来的)。
|
||||
//
|
||||
// 语义是**覆盖**:注入的值不像令牌时 Code 变空串,而不是保留构造函数解出来的旧值。
|
||||
func (e *UpstreamErrorInfo) SetCode(code string) { e.Code = ErrorToken(code) }
|
||||
|
||||
// SetType 是 Type 版的 SetCode,口径逐字相同。
|
||||
func (e *UpstreamErrorInfo) SetType(typ string) { e.Type = ErrorToken(typ) }
|
||||
|
||||
// upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段,
|
||||
// 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。
|
||||
type upstreamErrorBody struct {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package trace
|
||||
|
||||
// 本文件是 M49 的核心断言:**守卫在构造函数里**。
|
||||
//
|
||||
// M46 把令牌守卫加在调用方(model / voice 各一份),M48 复审的中危 finding 证明那是
|
||||
// 漂移形态 —— 构造函数自己不守,任何新调用点默认就不安全,四条出口(httpclient /
|
||||
// vision / embedding / weixin)恰恰一直漏着。这里把断言钉在 UpstreamError 与
|
||||
// SetCode / SetType 上:摘掉构造函数里的守卫,本文件与四条出口的用例同时变红。
|
||||
//
|
||||
// 每条都是「把它放宽就变红」的形状:断言的是真正进日志的两个出口(Error() / Attrs())。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUpstreamError_ConstructorDropsFreeTextCodeAndType 报文里 error.code 与
|
||||
// error.type 都是回显的散文 ⇒ 两个字段在构造时就变空串,而 status / body_bytes /
|
||||
// body_sha256 仍在(诊断面不能一起丢)。
|
||||
func TestUpstreamError_ConstructorDropsFreeTextCodeAndType(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
|
||||
|
||||
err := UpstreamError("api.example.com", 400, "application/json", body)
|
||||
info, ok := err.(*UpstreamErrorInfo)
|
||||
if !ok {
|
||||
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
|
||||
}
|
||||
if info.Code != "" || info.Type != "" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 空串(构造即安全)", info.Code, info.Type)
|
||||
}
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
|
||||
for _, surface := range []string{err.Error(), logBuf.String(), strings.Join(anyToStrings(info.Attrs()), " ")} {
|
||||
if strings.Contains(surface, "她刚才说的那句话") {
|
||||
t.Fatalf("散文经 code/type 进了出口:%s", surface)
|
||||
}
|
||||
if strings.Contains(surface, " code=") || strings.Contains(surface, " type=") {
|
||||
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%s", surface)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"status=400", "body_bytes=", "body_sha256=", "api.example.com"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q,want 含 %q(诊断线索不能一起丢)", err.Error(), want)
|
||||
}
|
||||
}
|
||||
if !errors.Is(err, ErrUpstream) {
|
||||
t.Fatal("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType 真实的协议枚举必须原样
|
||||
// 通过:丢了它们只是少一条分类提示,但那是**有价值的**提示,别顺手一起丢。
|
||||
func TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := []byte(`{"error":{"code":"invalid_request_error","type":"Throttling.RateQuota"}}`)
|
||||
err := UpstreamError("api.example.com", 429, "application/json", body)
|
||||
info := err.(*UpstreamErrorInfo)
|
||||
if info.Code != "invalid_request_error" || info.Type != "Throttling.RateQuota" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 原值(令牌形状必须保留)", info.Code, info.Type)
|
||||
}
|
||||
for _, want := range []string{"code=invalid_request_error", "type=Throttling.RateQuota"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q,want 含 %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamError_SetCodeGuardsPostConstructionInjection 构造后注入这条窄路也必须
|
||||
// 是守卫点:SetCode / SetType 是唯一合法通道,散文进去 = 空串出来(覆盖语义)。
|
||||
func TestUpstreamError_SetCodeGuardsPostConstructionInjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
const prose = "Invalid value: '她刚才说的那句话'"
|
||||
info := UpstreamError("dashscope.example.com", 400, "application/json", nil).(*UpstreamErrorInfo)
|
||||
|
||||
info.SetCode("Throttling.RateQuota")
|
||||
info.SetType("rate-limit")
|
||||
if info.Code != "Throttling.RateQuota" || info.Type != "rate-limit" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 令牌原值", info.Code, info.Type)
|
||||
}
|
||||
|
||||
// 注入不受控的散文:两个字段都变空串(而不是保留上一次的值)。
|
||||
info.SetCode(prose)
|
||||
info.SetType(prose)
|
||||
if info.Code != "" || info.Type != "" {
|
||||
t.Fatalf("info.Code/Type = %q/%q,want 空串(SetXxx 必须过同一道守卫)", info.Code, info.Type)
|
||||
}
|
||||
if text := info.Error(); strings.Contains(text, "她刚才说的那句话") {
|
||||
t.Fatalf("SetCode/SetType 让散文进了错误文本:%s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// 本文件是「从 net/http 的 *url.Error 里取回原因」的**唯一**实现。
|
||||
//
|
||||
// 为什么需要它:*url.Error 的 Error() 会把**整条地址**渲染进错误串 ——
|
||||
// `Get "https://h/p?access_token=…": dial tcp …`。而 net/http 在两条路上都会包出它:
|
||||
// 传输层失败(`client.Do`)与地址解析失败(`http.NewRequest`)。这条错误串会进日志、
|
||||
// 进工单、进模型可见视图,而平台的资源地址几乎都带凭据(预签名上传地址的
|
||||
// `encrypted_query_param`、CDN 的 `rkey`、Bot API 路径里的 `/bot<token>/`、OAuth 回调
|
||||
// 里的 `access_token`、user:pass@),于是「原样外抛」等于把凭据写进日志。
|
||||
//
|
||||
// 为什么不能 `fmt.Errorf("...: %w", err)` 直接外抛:%w 只给错误串加前缀,地址仍在串里
|
||||
// —— 包一层不是修复,只是换个位置泄漏。剥壳(取内层 cause)才是。
|
||||
//
|
||||
// 这段逻辑曾被五个客户端各写一份逐字同形的三行(mediafetch.sanitizeErr /
|
||||
// httpclient.transportCause / vision.fetchCause / telegram.callCause /
|
||||
// weixin_ilink.urlFreeCause,共 13 个调用点)。同形实现一分家,最容易发生的漂移是
|
||||
// 「其中一份为了多带点诊断把地址留下来」,而那正是凭据进日志的那一条路 —— 因此收敛
|
||||
// 到这里,调用点只剩一次函数调用(AGENTS.md §10「三次复制必须抽取」)。
|
||||
|
||||
// URLCause 剥掉 err 里**所有层**的 *url.Error 外壳,返回不携带任何 URL 的内层原因。
|
||||
//
|
||||
// 三条口径:
|
||||
// - **逐层剥**:内层若还是 *url.Error(调用方又包了一次 Do、或链上叠了两层),继续
|
||||
// 往下剥 —— 只剥一层的话内层那条仍然带着同一段地址;
|
||||
// - **只丢地址、不丢原因**:返回的是原来的底层 error 本身(不新增包装),因此
|
||||
// errors.Is/As 对 context.Canceled、context.DeadlineExceeded、*net.DNSError 等
|
||||
// 哨兵仍然成立 —— 调用方的分类(重试 / 降级 / 熔断)不受影响,也绝不会把同一个
|
||||
// 原因在文案里说两遍;
|
||||
// - **非 *url.Error 原样返回**:同一个值、不加包装,可安全当作「可能剥壳、也可能
|
||||
// 什么都没做」的透传。
|
||||
//
|
||||
// 唯一的例外是 `*url.Error` 没有内层原因(Err 为 nil)或**层数超限**:那种错误
|
||||
// **无法**在不带地址的前提下原样返回,于是退化成只含操作名(Get/Post/parse)的替身
|
||||
// —— 宁可少一条原因,也不回落到原串(回落就是泄漏)。net/http 的两处构造都填了
|
||||
// Err,且叠不过两层,因此这条路在真实调用中到不了。
|
||||
//
|
||||
// 终止条件**只靠层数上限**(maxURLCauseDepth),不靠「拿两个 error 比相等」:
|
||||
// - 比相等挡不住**多节点环**(a.Err=b / b.Err=a),那种链会一直转到栈外的调用方
|
||||
// 超时为止 —— 一个错误路径上的死循环等于打挂当前请求的 goroutine;
|
||||
// - 接口值 `==` 在两侧动态类型相同且**不可比较**(含 slice/map 的错误实现)时
|
||||
// **panic**(`comparing uncomparable type`),而 panic 同样打挂当前请求的
|
||||
// goroutine。今天 13 个调用点传的都是 net/http 现场构造的可比较无环错误,因此
|
||||
// 这条路不可达;但「今天不可达」会随时间衰减,而护栏的成本只是一次计数。
|
||||
//
|
||||
// 超限时的口径:**返回只含 Op 的替身,不返回当前层**。当前层仍是 `*url.Error`,
|
||||
// 它的 Error() 里就是整条地址 —— 为防死循环而把地址带出去,正是本文件要修的病害
|
||||
// 反过来复发。诊断面只丢「更内层的原因」,status/host 由调用方的文案补齐。
|
||||
func URLCause(err error) error {
|
||||
for depth := 0; ; depth++ {
|
||||
var uerr *url.Error
|
||||
if !errors.As(err, &uerr) {
|
||||
return err
|
||||
}
|
||||
if uerr.Err == nil || depth >= maxURLCauseDepth {
|
||||
return urlOpOnly(uerr.Op)
|
||||
}
|
||||
err = uerr.Err
|
||||
}
|
||||
}
|
||||
|
||||
// maxURLCauseDepth 是剥壳的层数上限,**唯一的终止条件**(见 URLCause)。取 8 的
|
||||
// 理由:真实链最多两层(`client.Do` 包一次、调用方再 `%w` 一层),8 是它的四倍余量,
|
||||
// 正常路径永远碰不到;同时小到环/超深链在微秒级就停住,不会拖住请求。
|
||||
const maxURLCauseDepth = 8
|
||||
|
||||
// urlOpOnly 是「*url.Error 没有可用内层原因」时的替身:只留操作名,地址一个字节都不留。
|
||||
func urlOpOnly(op string) error {
|
||||
return errors.New("url " + op + " failed")
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package trace
|
||||
|
||||
// 本文件钉住 URLCause 的唯一职责:把 *url.Error 剥到内层 cause,且**不携带任何 URL**。
|
||||
//
|
||||
// 为什么值得单独一条:net/http 在传输层失败与地址解析失败时都会包出 *url.Error,
|
||||
// 而它的 Error() 里就是整条地址 —— 平台的资源地址几乎都带签名凭据(预签名 URL、
|
||||
// CDN rkey、Bot API 的 /bot<token>/、OAuth 的 access_token、user:pass@)。旧实现是
|
||||
// 五个客户端各写一份同形的三行剥离,任一份漂移就等于多一条凭据进日志的路。
|
||||
//
|
||||
// 每条用例都是「把它改回去就变红」的形状:断言的是剥壳**之后**的错误文本,也就是
|
||||
// 调用方实际打进日志的那段字符串。
|
||||
//
|
||||
// 另一半是**终止性**:剥壳跑在错误路径上,所以它自己不许成为故障源 —— 环(自引用、
|
||||
// 两节点环)不许死循环,不可比较的动态类型不许 panic,超限时也不许把地址带出来。
|
||||
// 那几条用例用「1s 内必须返回 + recover 到的 panic 一律判红」钉住(见 urlCauseWithin)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// urlCauseTests 覆盖带凭据的三种地址形态:查询串里的 access_token、user:pass@
|
||||
// 用户信息、以及路径里的 token(Bot API 把 bot token 写在路径里)。
|
||||
var urlCauseTests = []struct {
|
||||
name string
|
||||
rawURL string
|
||||
secret string
|
||||
}{
|
||||
{"query-access-token", "https://cdn.example.com/a/b?access_token=QUERY-ACCESS-TOKEN&x=1", "QUERY-ACCESS-TOKEN"},
|
||||
{"userinfo", "https://user:USER-PASS-SECRET@api.example.com/x", "USER-PASS-SECRET"},
|
||||
{"path-token", "https://api.example.com/bot12345:BOT-TOKEN-SECRET/getUpdates", "BOT-TOKEN-SECRET"},
|
||||
}
|
||||
|
||||
// TestURLCauseStripsURLKeepsCause 剥壳后错误串里只剩原因:地址、路径、查询串、用户
|
||||
// 信息一个片段都不出现,而底层原因仍然在。改回原样 `%w` 外抛(或只剥一层)就变红。
|
||||
func TestURLCauseStripsURLKeepsCause(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := errors.New("dial tcp 10.0.0.1:443: connection refused")
|
||||
for _, tc := range urlCauseTests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := URLCause(&url.Error{Op: "Get", URL: tc.rawURL, Err: inner})
|
||||
msg := got.Error()
|
||||
if strings.Contains(msg, tc.secret) {
|
||||
t.Fatalf("地址里的凭据进了错误:%v", msg)
|
||||
}
|
||||
for _, fragment := range []string{"https://", "cdn.example.com", "api.example.com", "/bot12345", "?x=1"} {
|
||||
if strings.Contains(msg, fragment) {
|
||||
t.Fatalf("地址片段 %q 进了错误:%v", fragment, msg)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "connection refused") {
|
||||
t.Fatalf("剥掉地址时把原因一起丢了:%v", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCauseKeepsSentinelChain 剥壳不得破坏哨兵链:调用方靠 errors.Is 分类
|
||||
// (重试 / 降级 / 熔断),而地址只是副作用面。改回「重新构造一条新错误」就变红。
|
||||
func TestURLCauseKeepsSentinelChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := &url.Error{Op: "Get", URL: "https://h/x?access_token=S", Err: context.DeadlineExceeded}
|
||||
got := URLCause(err)
|
||||
if !errors.Is(got, context.DeadlineExceeded) {
|
||||
t.Fatalf("哨兵链断了:%v", got)
|
||||
}
|
||||
if strings.Contains(got.Error(), "access_token") {
|
||||
t.Fatalf("剥壳后仍带地址:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCauseStripsEveryLayer 内层若还是 *url.Error(调用方又包了一次 Do、或链上
|
||||
// 叠了两层),必须一起剥 —— 只剥一层的话内层那条仍然带着同一段地址。
|
||||
func TestURLCauseStripsEveryLayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := errors.New("connection reset by peer")
|
||||
nested := &url.Error{Op: "Get", URL: "https://inner.example.com/p?access_token=INNER-SECRET", Err: inner}
|
||||
outer := &url.Error{Op: "Get", URL: "https://outer.example.com/p?access_token=OUTER-SECRET", Err: nested}
|
||||
|
||||
msg := URLCause(outer).Error()
|
||||
for _, secret := range []string{"INNER-SECRET", "OUTER-SECRET"} {
|
||||
if strings.Contains(msg, secret) {
|
||||
t.Fatalf("某一层的凭据进了错误(%s):%v", secret, msg)
|
||||
}
|
||||
}
|
||||
if !errors.Is(URLCause(outer), inner) {
|
||||
t.Fatalf("多层剥壳后没回到最内层原因:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCauseStripsUnderOuterWrapper 调用方用 %w 包过一层时同样剥掉:外层前缀是
|
||||
// 我们自己的文案,带地址的仍然是里层那个 *url.Error。
|
||||
func TestURLCauseStripsUnderOuterWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := errors.New("i/o timeout")
|
||||
wrapped := fmt.Errorf("client: %w", &url.Error{Op: "Post", URL: "https://h/p?access_token=WRAPPED-SECRET", Err: inner})
|
||||
msg := URLCause(wrapped).Error()
|
||||
if strings.Contains(msg, "WRAPPED-SECRET") || strings.Contains(msg, "https://") {
|
||||
t.Fatalf("包一层的 *url.Error 仍把地址带了出来:%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "i/o timeout") {
|
||||
t.Fatalf("剥掉地址时把原因一起丢了:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCausePassesThroughNonURL 非 *url.Error 原样返回(同一个值、不加包装):
|
||||
// 五个调用点把它当「可能剥壳、可能什么都没做」的透传,多包一层会把同一个原因在
|
||||
// 文案里说两遍。
|
||||
func TestURLCausePassesThroughNonURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
sentinel := errors.New("boom")
|
||||
if got := URLCause(sentinel); got != sentinel {
|
||||
t.Fatalf("非 *url.Error 被改写了:got %v", got)
|
||||
}
|
||||
wrapped := fmt.Errorf("ctx: %w", sentinel)
|
||||
if got := URLCause(wrapped); got != wrapped {
|
||||
t.Fatalf("非 *url.Error 的包装被拆开了:got %v", got)
|
||||
}
|
||||
if got := URLCause(nil); got != nil {
|
||||
t.Fatalf("nil 被改写成了 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCauseWithoutCauseStaysURLLess *url.Error 没有内层原因时(Err 为 nil)也无法
|
||||
// 原样返回 —— 它照样会把地址渲染进 Error()。此时只留操作名:宁可少一条原因,也不
|
||||
// 回落到原串。改回「Err 为 nil 就 return err」就变红。
|
||||
func TestURLCauseWithoutCauseStaysURLLess(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := URLCause(&url.Error{Op: "Get", URL: "https://h/p?access_token=NIL-CAUSE-SECRET"})
|
||||
if got == nil {
|
||||
t.Fatal("返回了 nil")
|
||||
}
|
||||
msg := got.Error()
|
||||
if strings.Contains(msg, "NIL-CAUSE-SECRET") || strings.Contains(msg, "https://") {
|
||||
t.Fatalf("没有内层原因时回落到了原串(泄漏地址):%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "Get") {
|
||||
t.Fatalf("连操作名都没留下(诊断面全丢):%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// urlCauseWithin 在后台调 URLCause,**1s 内没回来**就当死循环判红;期间发生的
|
||||
// panic 被 recover 成第二个返回值。两条都是「错误路径炸掉当前请求 goroutine」的
|
||||
// 形状,因此都算红 —— 而剥壳本身在真实链上只要几纳秒,1s 是一万倍的余量。
|
||||
func urlCauseWithin(t *testing.T, err error) (cause error, panicked any) {
|
||||
t.Helper()
|
||||
type outcome struct {
|
||||
cause error
|
||||
reason any
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
var out outcome
|
||||
defer func() {
|
||||
out.reason = recover()
|
||||
done <- out
|
||||
}()
|
||||
out.cause = URLCause(err)
|
||||
}()
|
||||
select {
|
||||
case out := <-done:
|
||||
return out.cause, out.reason
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("URLCause 1s 内没有返回:终止条件失效(错误路径上的死循环)")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// urlCauseRedlines 是「剥壳结果里一个地址片段都不许出现」的统一断言。
|
||||
var urlCauseRedlines = []string{"https://", ".example.com", "access_token", "SECRET"}
|
||||
|
||||
// assertURLLess 断言剥壳结果非 nil 且不含任何地址片段,并回传错误文本。
|
||||
func assertURLLess(t *testing.T, got error) string {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatal("返回了 nil")
|
||||
}
|
||||
msg := got.Error()
|
||||
for _, fragment := range urlCauseRedlines {
|
||||
if strings.Contains(msg, fragment) {
|
||||
t.Fatalf("地址片段 %q 进了错误:%v", fragment, msg)
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestURLCauseSelfReferentialChainTerminates 自引用链(a.Err=a)必须终止,且退化成
|
||||
// 只含操作名的替身:回到「返回当前层」或「原串」都会把地址带出来。
|
||||
func TestURLCauseSelfReferentialChainTerminates(t *testing.T) {
|
||||
t.Parallel()
|
||||
self := &url.Error{Op: "Post", URL: "https://h/p?access_token=SELF-SECRET"}
|
||||
self.Err = self
|
||||
|
||||
got, panicked := urlCauseWithin(t, self)
|
||||
if panicked != nil {
|
||||
t.Fatalf("自引用链上 panic 了:%v", panicked)
|
||||
}
|
||||
if msg := assertURLLess(t, got); msg != "url Post failed" {
|
||||
t.Fatalf("自引用链没有退化成只含 Op 的替身:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCauseTwoNodeCycleTerminates 两节点环(a.Err=b / b.Err=a)必须终止:只认
|
||||
// 「长度 1 的自引用」的终止条件在这里会一直转下去(旧实现的形状),把当前请求的
|
||||
// goroutine 卡死。
|
||||
func TestURLCauseTwoNodeCycleTerminates(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &url.Error{Op: "Get", URL: "https://a.example.com/p?access_token=CYCLE-A-SECRET"}
|
||||
b := &url.Error{Op: "Get", URL: "https://b.example.com/p?access_token=CYCLE-B-SECRET"}
|
||||
a.Err = b
|
||||
b.Err = a
|
||||
|
||||
got, panicked := urlCauseWithin(t, a)
|
||||
if panicked != nil {
|
||||
t.Fatalf("两节点环上 panic 了:%v", panicked)
|
||||
}
|
||||
msg := assertURLLess(t, got)
|
||||
if strings.Contains(msg, "CYCLE-A-SECRET") || strings.Contains(msg, "CYCLE-B-SECRET") {
|
||||
t.Fatalf("环上终止把地址带了出来:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// uncomparableErr 的**动态类型不可比较**(struct 值里带 slice):接口值 `==` 拿它
|
||||
// 比较会 panic(comparing uncomparable type),而 Unwrap 指回外层 *url.Error 让整条
|
||||
// 链成环。两者合起来正是「今天的终止条件换成不可比较的 error 就 panic」的形状。
|
||||
type uncomparableErr struct {
|
||||
parts []string
|
||||
wrap error
|
||||
}
|
||||
|
||||
func (e uncomparableErr) Error() string { return strings.Join(e.parts, " ") }
|
||||
func (e uncomparableErr) Unwrap() error { return e.wrap }
|
||||
|
||||
// TestURLCauseUncomparableDynamicTypeDoesNotPanic 不可比较的动态类型不得 panic:
|
||||
// 错误路径上的 panic 会打挂当前请求的 goroutine。改成 `uerr.Err == err` 就变红。
|
||||
func TestURLCauseUncomparableDynamicTypeDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
outer := &url.Error{Op: "Post", URL: "https://api.example.com/p?access_token=UNCOMPARABLE-SECRET"}
|
||||
outer.Err = uncomparableErr{parts: []string{"dial", "tcp", "refused"}, wrap: outer}
|
||||
|
||||
got, panicked := urlCauseWithin(t, outer)
|
||||
if panicked != nil {
|
||||
t.Fatalf("不可比较的动态类型上 panic 了(错误路径上的 panic 会打挂当前请求):%v", panicked)
|
||||
}
|
||||
assertURLLess(t, got)
|
||||
}
|
||||
|
||||
// TestURLCauseDepthCapKeepsURLLess 链深超过上限时**仍然不许**出现地址:为防死循环
|
||||
// 而退回「当前层」(*url.Error,Error() 里就是整条地址)等于把本文件要修的病害从
|
||||
// 后门放回来。超限的口径是「只含 Op 的替身」,连带把更深层的原因一起丢掉。
|
||||
func TestURLCauseDepthCapKeepsURLLess(t *testing.T) {
|
||||
t.Parallel()
|
||||
var chain error = errors.New("connection reset by peer")
|
||||
for i := maxURLCauseDepth + 4; i > 0; i-- {
|
||||
chain = &url.Error{
|
||||
Op: "Get",
|
||||
URL: fmt.Sprintf("https://h%d.example.com/p?access_token=DEEP-%d-SECRET", i, i),
|
||||
Err: chain,
|
||||
}
|
||||
}
|
||||
|
||||
got, panicked := urlCauseWithin(t, chain)
|
||||
if panicked != nil {
|
||||
t.Fatalf("超深链上 panic 了:%v", panicked)
|
||||
}
|
||||
if msg := assertURLLess(t, got); msg != "url Get failed" {
|
||||
t.Fatalf("超限后的口径不是「只含 Op 的替身」:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestURLCauseStripsExactlyUpToCap 上限之内的深链仍然**逐层剥到**内层原因:护栏只
|
||||
// 收紧「超过上限」那一侧,不得顺带削掉正常路径的诊断面。
|
||||
func TestURLCauseStripsExactlyUpToCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := errors.New("i/o timeout")
|
||||
var chain error = inner
|
||||
for i := 0; i < maxURLCauseDepth; i++ {
|
||||
chain = &url.Error{Op: "Get", URL: fmt.Sprintf("https://h%d.example.com/p?access_token=CAP-%d-SECRET", i, i), Err: chain}
|
||||
}
|
||||
|
||||
got, panicked := urlCauseWithin(t, chain)
|
||||
if panicked != nil {
|
||||
t.Fatalf("上限内的深链上 panic 了:%v", panicked)
|
||||
}
|
||||
assertURLLess(t, got)
|
||||
if !errors.Is(got, inner) {
|
||||
t.Fatalf("%d 层链(正好等于上限)没有剥到内层原因:%v", maxURLCauseDepth, got)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本包的哨兵错误。每个都以 %w 包装,调用方既能按类别判断,也拿得到原始原因。
|
||||
@@ -171,7 +173,7 @@ func (c *Client) speak(ctx context.Context, character, text string) (Audio, erro
|
||||
endpoint := c.cfg.BaseURL + speechPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Audio{}, fmt.Errorf("voice/tts: build request: %w", err)
|
||||
return Audio{}, fmt.Errorf("voice/tts: build request: %w", trace.URLCause(err))
|
||||
}
|
||||
// Content-Type 必须是 application/json:正文本身已是 UTF-8(见文件头)。
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -212,6 +214,15 @@ func encodeSpeech(text, character string) ([]byte, error) {
|
||||
|
||||
// unreachable 把"没拿到响应"收敛成 ErrUnreachable(超时/取消/连接失败都归这里),
|
||||
// 并记一条带来源的 WARN。正文**不进日志**。
|
||||
//
|
||||
// 原因在**进日志与进错误之前**先过 trace.URLCause:`client.Do` 回来的 `*url.Error`
|
||||
// 会把整条 endpoint(含 userinfo / query 里的签名)渲染进错误串,而本函数把同一个
|
||||
// err 既写日志又包进返回值 —— 只脱敏 endpoint 字段是不够的,那条 err 会把地址
|
||||
// 原样带出去。因此 endpoint 只以 redactURL 的形态出现,err 只以「已剥壳的原因」出现
|
||||
// (地址不外泄一遍,也不会两处各出现一遍)。
|
||||
//
|
||||
// 分类仍按**原来的 err** 判断(不按剥壳后的 cause):本函数唯一的改变是「少一段地址」,
|
||||
// ErrUnreachable 与 context.* 哨兵链、WARN 的 reason 取值都不受影响。
|
||||
func (c *Client) unreachable(ctx context.Context, endpoint string, err error) error {
|
||||
reason := "connection failed"
|
||||
switch {
|
||||
@@ -222,8 +233,9 @@ func (c *Client) unreachable(ctx context.Context, endpoint string, err error) er
|
||||
case errors.Is(err, context.Canceled):
|
||||
reason = "context canceled"
|
||||
}
|
||||
c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", err)
|
||||
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, err)
|
||||
cause := trace.URLCause(err)
|
||||
c.warn("voice tts unreachable", "reason", reason, "endpoint", redactURL(endpoint), "err", cause)
|
||||
return fmt.Errorf("%w: %s: %w", ErrUnreachable, reason, cause)
|
||||
}
|
||||
|
||||
// upstreamStatus 把非 200 收敛成 ErrUpstream,并只记**元数据**(状态码、Content-Type
|
||||
@@ -294,16 +306,29 @@ type Audio struct {
|
||||
// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host,
|
||||
// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。
|
||||
//
|
||||
// 解析失败或没有 host 时**不能**原样返回:`http://user:pass@` 这种不合法地址会
|
||||
// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的
|
||||
// 分隔符,出现在地址里只可能是凭据)。
|
||||
// 解析不出 host 时**不能**原样返回,两条兜底各挡一类泄漏:
|
||||
// - 含 `@`(userinfo 的分隔符,出现在地址里只可能是凭据)⇒ 整体打码。
|
||||
// `http://user:pass@` 这种不合法地址因此不会把凭据整个写进日志。
|
||||
// - 其余多半是**漏了 scheme** 的地址(`127.0.0.1:9880/v1?token=…`:`url.Parse`
|
||||
// 在首段的 `:` 上报错,host 为空)。漏 scheme 不是"原样返回"的理由 ——
|
||||
// 签名与 token 就在 query 里,因此按 `?`/`#` 切掉查询串与片段再返回。
|
||||
func redactURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
if strings.Contains(raw, "@") {
|
||||
return "<redacted>"
|
||||
}
|
||||
return raw
|
||||
if err == nil && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
if strings.Contains(raw, "@") {
|
||||
return "<redacted>"
|
||||
}
|
||||
return stripURLQuery(raw)
|
||||
}
|
||||
|
||||
// stripURLQuery 切掉 raw 里 `?` 或 `#` 起的部分。它是 redactURL 在「解析不出
|
||||
// host」那条路径上的兜底:那里拿不到 url.URL 的 RawQuery/Fragment,只能按
|
||||
// 分隔符切。没有分隔符时原样返回(不重新编码、不规范化,便于肉眼比对)。
|
||||
func stripURLQuery(raw string) string {
|
||||
if i := strings.IndexAny(raw, "?#"); i >= 0 {
|
||||
return raw[:i]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -6,7 +6,8 @@ package tts
|
||||
// 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进
|
||||
// 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。
|
||||
// 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL
|
||||
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。
|
||||
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志;而**漏了 scheme** 的
|
||||
// 地址(`127.0.0.1:9880/v1?token=…`)走的是同一条兜底,query 必须被切掉。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -121,3 +122,42 @@ func TestRedactURLCredentials(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactURLNoSchemeDropsQuery 钉住 M55 的缺口:**漏了 scheme** 的地址
|
||||
// (`127.0.0.1:9880/v1?token=…`)在 `url.Parse` 里是错误 —— 首段带 `:` ——
|
||||
// 于是 host 为空、走兜底。旧兜底把原串连 query 一起返回,签名/token 就这么进了
|
||||
// 日志与工单。现在兜底只按 `?`/`#` 切掉查询串与片段。
|
||||
//
|
||||
// 变异(两种,各自被不同的子用例杀灭):
|
||||
// - 兜底改回 `raw` ⇒ 带 query 的三条用例全红(secret / `token=` 会出现在结果里);
|
||||
// - `stripURLQuery` 退化成**只切 `?`** ⇒ 只有「片段先于查询」那条红
|
||||
// (`#access_token=…` 原样留在结果里)。「query 与片段」那条**打不红** ——
|
||||
// `?` 在 `#` 之前,只切 `?` 会顺带把片段带走 —— 所以 `#` 先行这条分支必须由
|
||||
// 「片段先于查询」独立钉住(review M55 r1 的 P2-01)。
|
||||
func TestRedactURLNoSchemeDropsQuery(t *testing.T) {
|
||||
const secret = "TTS-SECRET-7b3e"
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"无 scheme 带 query", "127.0.0.1:9880/v1/audio/speech?token=" + secret, "127.0.0.1:9880/v1/audio/speech"},
|
||||
{"无 scheme 带 query 与片段", "tts.internal:9880/v1?token=" + secret + "#frag", "tts.internal:9880/v1"},
|
||||
{"无 scheme 片段先于查询", "127.0.0.1:9880/v1#access_token=" + secret, "127.0.0.1:9880/v1"},
|
||||
{"无 scheme 无 query 原样", "127.0.0.1:9880", "127.0.0.1:9880"},
|
||||
{"无 scheme 带 userinfo ⇒ 整体打码", "user:pass@host/v1?token=" + secret, "<redacted>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := redactURL(c.in)
|
||||
if got != c.want {
|
||||
t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
for _, leak := range []string{secret, "token=", "pass"} {
|
||||
if strings.Contains(got, leak) {
|
||||
t.Fatalf("可打日志的地址里出现了 %q: %q", leak, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package tts
|
||||
|
||||
// 本文件是 M52 的出口回归探针:emotionTTS 客户端里两条会把整条 endpoint 带出去的路
|
||||
// ——`speak` 的建请求失败、`unreachable` 的传输失败 —— 都先过 trace.URLCause。
|
||||
//
|
||||
// 这一处比别处更绕:日志行早就用 `redactURL(endpoint)` 脱敏了,但**同一个 err 还被
|
||||
// 同一行当作 `"err", err` 打出去**,而返回值又把它 `%w` 包着带回去 ⇒ 日志侧那道脱敏
|
||||
// 等于被绕过(地址从 err 字段原样出现)。所以这里的断言是双面的:
|
||||
// - 错误串里没有地址;
|
||||
// - 日志里那条地址**只出现一次**(脱敏那次),不出现第二遍原串。
|
||||
//
|
||||
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两处各一个用例。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
urlCauseQuerySecret = "SECRET-2f8a1c"
|
||||
urlCauseUser = "svc-user"
|
||||
urlCauseBadHost = "bad host.example.com"
|
||||
)
|
||||
|
||||
func ttsURLCauseGoodBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@tts.example.com?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
func ttsURLCauseBadBase() string {
|
||||
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "?access_token=" + urlCauseQuerySecret
|
||||
}
|
||||
|
||||
// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。
|
||||
type urlCauseFailTransport struct{}
|
||||
|
||||
func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp 10.0.0.1:9880: connect: connection refused")
|
||||
}
|
||||
|
||||
// TestUnreachableDropsURLFromErrorAndLog 钉住 unreachable:错误串与日志都只留原因,
|
||||
// 地址(含 query 凭据与用户名)一个字节都不出现,且日志里那条地址只出现一次。
|
||||
func TestUnreachableDropsURLFromErrorAndLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
var logBuf bytes.Buffer
|
||||
c := New(
|
||||
Config{Enabled: true, BaseURL: ttsURLCauseGoodBase()},
|
||||
WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}),
|
||||
WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))),
|
||||
)
|
||||
|
||||
_, err := c.Speech(context.Background(), "char_1", "hi")
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Fatalf("err = %v, want ErrUnreachable(分类不许被带偏)", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
|
||||
}
|
||||
for _, surface := range []string{err.Error(), logBuf.String()} {
|
||||
if strings.Contains(surface, urlCauseQuerySecret) {
|
||||
t.Fatalf("query 里的凭据进了错误或日志:%s", surface)
|
||||
}
|
||||
if strings.Contains(surface, urlCauseUser) {
|
||||
t.Fatalf("userinfo 里的用户名进了错误或日志:%s", surface)
|
||||
}
|
||||
}
|
||||
// 「日志里不会出现两遍地址」:脱敏后的 host 只该由 endpoint 字段出现一次。
|
||||
if got := strings.Count(logBuf.String(), "tts.example.com"); got != 1 {
|
||||
t.Fatalf("日志里 host 出现 %d 次,want 1(endpoint 字段那次):%s", got, logBuf.String())
|
||||
}
|
||||
if strings.Contains(err.Error(), "tts.example.com") {
|
||||
t.Fatalf("错误串带出了 endpoint 的 host:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpeakDropsURLFromBuildRequestError 钉住 speak 里 NewRequest 失败那一处:
|
||||
// 畸形 base URL 的解析错误同样是 *url.Error,会把整条地址带出来。
|
||||
func TestSpeakDropsURLFromBuildRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
var logBuf bytes.Buffer
|
||||
c := New(
|
||||
Config{Enabled: true, BaseURL: ttsURLCauseBadBase()},
|
||||
WithHTTPClient(&http.Client{Transport: urlCauseFailTransport{}}),
|
||||
WithLogger(slog.New(slog.NewJSONHandler(&logBuf, nil))),
|
||||
)
|
||||
|
||||
_, err := c.Speech(context.Background(), "char_1", "hi")
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
|
||||
t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
|
||||
}
|
||||
if strings.Contains(logBuf.String(), urlCauseQuerySecret) {
|
||||
t.Fatalf("建请求失败也把地址写进了日志:%s", logBuf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ttslaunch
|
||||
|
||||
// 本文件锁定复审 P2-2 的两条收口(都是"改坏就会静默拉进程"的那种):
|
||||
//
|
||||
// 1. **调用方 ctx 取消 ≠ 没起**:后端关闭时探活被打断,必须**跳过**(不拉、不记
|
||||
// 冷却、不当故障),否则会在关闭过程中拉起 TTS;
|
||||
// 2. **探活地址必须合法**:漏 scheme / 拼错主机会让"没起"永远成立 ⇒ 每次启动都
|
||||
// 拉一次进程。非法地址 ⇒ WARN + 不启用,一次都不拉。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCheck_ParentCanceled_SkipsLaunch 钉住复审 P2-2a:
|
||||
// 探活挂在黑洞地址时取消父 ctx ⇒ 探活返回 context.Canceled ⇒ **跳过**。
|
||||
//
|
||||
// 变异点:把"取消"改回判"没起" ⇒ spawned 变 1、Outcome 变 launched/failed,本用例红。
|
||||
func TestCheck_ParentCanceled_SkipsLaunch(t *testing.T) {
|
||||
var spawned int32
|
||||
l := New(Config{Enabled: true, ProbeTimeout: time.Second},
|
||||
WithProbe(func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return fmt.Errorf("ttslaunch: probe: %w", ctx.Err())
|
||||
}),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan Outcome, 1)
|
||||
go func() { done <- l.Check(ctx) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got != OutcomeCanceled {
|
||||
t.Fatalf("Outcome = %v, want canceled(关闭期取消不该判成没起)", got)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("取消后 Check 没返回")
|
||||
}
|
||||
if n := atomic.LoadInt32(&spawned); n != 0 {
|
||||
t.Fatalf("关闭期取消仍拉起了 %d 次(want 0)", n)
|
||||
}
|
||||
// 取消不是一次"尝试":不该消耗冷却,否则关闭期恰好撞上冷却会让重启后第一次
|
||||
// 探活被跳过。
|
||||
if l.cooling() {
|
||||
t.Fatal("调用方取消被记成了一次拉起尝试(进了冷却)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheck_ProbeOwnTimeout_StillLaunches 钉住与上一条的分界线:**探活自身超时**
|
||||
// (context.DeadlineExceeded)仍然是"没起",要照常拉。
|
||||
//
|
||||
// 变异点:把"任何 ctx 错误都跳过"写进去 ⇒ 本用例红(变成 canceled、spawned=0)。
|
||||
func TestCheck_ProbeOwnTimeout_StillLaunches(t *testing.T) {
|
||||
var spawned int32
|
||||
l := New(Config{Enabled: true},
|
||||
WithProbe(func(context.Context) error {
|
||||
return fmt.Errorf("ttslaunch: probe: %w", context.DeadlineExceeded)
|
||||
}),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||||
t.Fatalf("Outcome = %v, want launched(探活自身超时 = 没起)", got)
|
||||
}
|
||||
if n := atomic.LoadInt32(&spawned); n != 1 {
|
||||
t.Fatalf("拉起 %d 次, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_InvalidProbeURL_Disables 钉住复审 P2-2b:非法探活地址 ⇒ WARN + **不启用**,
|
||||
// 一次探活都不发、一次都不拉。
|
||||
//
|
||||
// 变异点:去掉 New 里的 ValidateProbeURL ⇒ 这些地址下 Trigger 会接单(最终每轮
|
||||
// 都去拉进程),本用例红。
|
||||
func TestNew_InvalidProbeURL_Disables(t *testing.T) {
|
||||
cases := []string{
|
||||
"127.0.0.1:9880", // 漏 scheme(url.Parse 把 127.0.0.1 当 scheme)
|
||||
"localhost:9880", // 同上
|
||||
"//127.0.0.1:9880", // 协议相对,没有 scheme
|
||||
"http://", // 有 scheme 没主机
|
||||
"ftp://127.0.0.1:9880", // scheme 不对
|
||||
"http://tts local:9880", // 空格
|
||||
"http://[::1", // 未闭合方括号
|
||||
}
|
||||
for _, raw := range cases {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
var spawned int32
|
||||
log, buf := captureLogger()
|
||||
l := New(Config{Enabled: true, ProbeURL: raw}, WithLogger(log),
|
||||
WithProbe(func(context.Context) error {
|
||||
t.Error("非法地址不该再发探活")
|
||||
return nil
|
||||
}),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
if l.Trigger(context.Background()) {
|
||||
t.Fatal("非法 probeUrl 仍接单了(会每次启动都拉一次进程)")
|
||||
}
|
||||
if got := l.Check(context.Background()); got != OutcomeDisabled {
|
||||
t.Fatalf("Check = %v, want disabled", got)
|
||||
}
|
||||
l.Wait()
|
||||
if n := atomic.LoadInt32(&spawned); n != 0 {
|
||||
t.Fatalf("非法地址却拉起了 %d 次", n)
|
||||
}
|
||||
if out := buf.String(); !strings.Contains(out, "invalid probe url") {
|
||||
t.Fatalf("非法地址没记 WARN: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_ValidProbeURL_StaysEnabled 是对照组:合法地址(含主机名与 https)必须
|
||||
// 照常启用 —— 免得把校验写成"一律不启用"。
|
||||
func TestNew_ValidProbeURL_StaysEnabled(t *testing.T) {
|
||||
for _, raw := range []string{"http://127.0.0.1:9880", "https://tts.local:9880/health"} {
|
||||
l := New(Config{Enabled: true, ProbeURL: raw},
|
||||
WithProbe(func(context.Context) error { return nil }))
|
||||
if got := l.Check(context.Background()); got != OutcomeAlreadyRunning {
|
||||
t.Fatalf("%s: Outcome = %v, want already_running", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Package ttslaunch 在**后端已经起好之后**顺手照顾半云端的 emotionTTS:
|
||||
// 先探一次 `http://127.0.0.1:9880`,没在跑就异步拉起主人给的
|
||||
// `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`(切片 M47)。
|
||||
//
|
||||
// 主人 2026-10-24 逐字:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便
|
||||
// 检测一下如果TTS没起就顺带启一下」。因此本包的三条口径是硬要求:
|
||||
//
|
||||
// 1. **只是便利,不进启动路径**:`Trigger` 把整段工作(探活 + 起进程)丢进后台
|
||||
// goroutine 后**立即返回**,探活超时/起进程耗时都**不计入**后端启动时长。
|
||||
// 2. **失败只 WARN**:探活超时、脚本不存在、起进程失败一律落到一条带 source 的
|
||||
// 日志,`Trigger` 的返回值只说「这次有没有接单」,**没有 error 通道** —— 一个
|
||||
// 没装 emotionTTS 的机器必须与改动前逐字一样地起得来。
|
||||
// 3. **绝不重复拉起**:同进程内并发触发只会有一次探活/拉起(单飞),一次尝试之后
|
||||
// 还有一段冷却,避免「后端连着重启」把 TTS 拉成好几个抢同一个端口的进程。
|
||||
//
|
||||
// 默认**关**(主人早前:「开机自启先不急 后面做」):`Config.Enabled` 为 false 时
|
||||
// 连探活都不发。开关与脚本路径走面板文件 `$DATA_DIR/config/voice-tts-launch.json`
|
||||
// (零新 `.env` 键,见 `internal/config/runtimeconfig`)。
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultProbeURL 是 emotionTTS 的默认探活地址(只绑回环,无鉴权)。
|
||||
DefaultProbeURL = "http://127.0.0.1:9880"
|
||||
// DefaultScriptPath 是主人给的「一键启动.bat」默认路径。
|
||||
DefaultScriptPath = `D:\Project\Service\emotionTTS_v5.1.0\一键启动.bat`
|
||||
// DefaultProbeTimeout 是单次探活超时:**毫秒级**。它只决定「等多久认定没起」,
|
||||
// 该值故意远小于任何"服务慢慢初始化"的量级 —— 判错的代价只是多拉一次脚本
|
||||
// (已有实例会自己抢端口失败),而等太久的代价是每次都白等。
|
||||
DefaultProbeTimeout = 300 * time.Millisecond
|
||||
// DefaultCooldown 是一次拉起尝试之后的冷却:这段时间内不再探、不再拉。
|
||||
DefaultCooldown = 2 * time.Minute
|
||||
)
|
||||
|
||||
// Config 是这一级的行为旋钮。零值经 Normalized 变成"默认关 + 默认地址/脚本/超时"。
|
||||
type Config struct {
|
||||
// Enabled 是总开关(默认 false)。关着时 Trigger 连探活都不会发。
|
||||
Enabled bool
|
||||
// ScriptPath 是拉起用的启动脚本;空 = DefaultScriptPath。
|
||||
ScriptPath string
|
||||
// ProbeURL 是探活地址;空 = DefaultProbeURL。
|
||||
ProbeURL string
|
||||
// ProbeTimeout 是单次探活超时;零/负 = DefaultProbeTimeout。
|
||||
ProbeTimeout time.Duration
|
||||
// Cooldown 是一次尝试之后的冷却;零/负 = DefaultCooldown。
|
||||
Cooldown time.Duration
|
||||
}
|
||||
|
||||
// Normalized 补齐缺省值(**唯一**的"缺省填什么"裁决处,调用方与配置层都不做)。
|
||||
func (c Config) Normalized() Config {
|
||||
if strings.TrimSpace(c.ScriptPath) == "" {
|
||||
c.ScriptPath = DefaultScriptPath
|
||||
}
|
||||
if strings.TrimSpace(c.ProbeURL) == "" {
|
||||
c.ProbeURL = DefaultProbeURL
|
||||
}
|
||||
if c.ProbeTimeout <= 0 {
|
||||
c.ProbeTimeout = DefaultProbeTimeout
|
||||
}
|
||||
if c.Cooldown <= 0 {
|
||||
c.Cooldown = DefaultCooldown
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Outcome 是一次同步检查(Check)的结论。它只用于日志与用例断言 —— 调用方(后端
|
||||
// 启动序列)**不**需要它,也拿不到任何 error。
|
||||
type Outcome int
|
||||
|
||||
const (
|
||||
// OutcomeDisabled 表示这一级没开(连探活都没发)。
|
||||
OutcomeDisabled Outcome = iota
|
||||
// OutcomeAlreadyRunning 表示探活成功:服务已经在跑,什么都不做。
|
||||
OutcomeAlreadyRunning
|
||||
// OutcomeLaunched 表示探活失败且启动脚本已被拉起。
|
||||
OutcomeLaunched
|
||||
// OutcomeCooling 表示还在上一次尝试的冷却期内,这次跳过。
|
||||
OutcomeCooling
|
||||
// OutcomeFailed 表示要拉但没拉起来(脚本不存在/起进程失败),已记 WARN。
|
||||
OutcomeFailed
|
||||
// OutcomeCanceled 表示探活被**调用方的 ctx 取消**打断(典型是后端正在关闭):
|
||||
// 这不是"没起",而是"这次不看了" —— 跳过、不拉起、也不消耗冷却。
|
||||
OutcomeCanceled
|
||||
)
|
||||
|
||||
// String 让日志与用例输出可读("outcome":"launched")。
|
||||
func (o Outcome) String() string {
|
||||
switch o {
|
||||
case OutcomeDisabled:
|
||||
return "disabled"
|
||||
case OutcomeAlreadyRunning:
|
||||
return "already_running"
|
||||
case OutcomeLaunched:
|
||||
return "launched"
|
||||
case OutcomeCooling:
|
||||
return "cooling"
|
||||
case OutcomeFailed:
|
||||
return "failed"
|
||||
case OutcomeCanceled:
|
||||
return "canceled"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Launcher 是探活 + 拉起的那一个实例:进程内只该建一个(它自己就是去重器)。
|
||||
//
|
||||
// 去重分两层,各自只管一件事:
|
||||
// - `inflight`(Trigger 里):同进程内并发触发只派**一个**后台 goroutine,
|
||||
// 后来者立即返回 false,连探活都不发。
|
||||
// - `lastAttempt` + `checkMu`(Check 里):一次**尝试**(探活后确实去拉了)之后
|
||||
// 记下时刻,冷却期内的再次检查直接 OutcomeCooling;`checkMu` 让两个直接调用
|
||||
// Check 的路径也被串起来,避免双双越过探活各拉一次。
|
||||
type Launcher struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
probe probeFunc
|
||||
spawn spawnFunc
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
inflight bool
|
||||
// lastAttempt 是上一次**拉起尝试**的时刻(零值 = 从未尝试过)。
|
||||
lastAttempt time.Time
|
||||
checkMu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// probeFunc 探一次活:nil = 已在跑,非 nil = 没起(或探不动,同样按没起处理)。
|
||||
type probeFunc func(ctx context.Context) error
|
||||
|
||||
// spawnFunc 拉起启动脚本:nil = 已拉起,非 nil = 没拉起来。
|
||||
type spawnFunc func(ctx context.Context) error
|
||||
|
||||
// Option 是 New 的可选覆盖(日志、探活、起进程、时钟)。生产路径只用 WithLogger;
|
||||
// 其余三项存在的意义是让"已在跑 / 拉起失败 / 冷却 / 并发"这些分支能被用例钉住,
|
||||
// 而不必真的去起进程。
|
||||
type Option func(*Launcher)
|
||||
|
||||
// WithLogger 注入日志器(nil 会被换成丢弃型日志,避免 nil 解引用)。
|
||||
func WithLogger(log *slog.Logger) Option {
|
||||
return func(l *Launcher) {
|
||||
if log != nil {
|
||||
l.log = log
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithProbe 覆盖探活实现(默认用 HTTP GET 打 ProbeURL,任何 HTTP 回应都算在跑)。
|
||||
func WithProbe(p probeFunc) Option {
|
||||
return func(l *Launcher) {
|
||||
if p != nil {
|
||||
l.probe = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSpawn 覆盖起进程实现(默认真起 ScriptPath 那个脚本)。
|
||||
func WithSpawn(s spawnFunc) Option {
|
||||
return func(l *Launcher) {
|
||||
if s != nil {
|
||||
l.spawn = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock 覆盖时钟(冷却的用例需要它,不必真等两分钟)。
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(l *Launcher) {
|
||||
if now != nil {
|
||||
l.now = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New 按 cfg 造一个 Launcher。cfg 在这里补齐缺省值(Config.Normalized)。
|
||||
func New(cfg Config, opts ...Option) *Launcher {
|
||||
cfg = cfg.Normalized()
|
||||
l := &Launcher{
|
||||
cfg: cfg,
|
||||
log: discardLogger(),
|
||||
now: time.Now,
|
||||
}
|
||||
l.probe = httpProbe(cfg.ProbeURL, cfg.ProbeTimeout)
|
||||
l.spawn = scriptSpawner(cfg.ScriptPath)
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(l)
|
||||
}
|
||||
}
|
||||
// 🔴 探活地址是一道**闸**(复审 P2-2b):写成 `127.0.0.1:9880` 这种漏了 scheme
|
||||
// 的形状,`http.NewRequest` 每次都失败 ⇒「没起」永远成立 ⇒ **每次启动都拉一次
|
||||
// 进程**(只有进程内冷却挡着)。宁可不启用,也不做这种自动重试机器。
|
||||
//
|
||||
// 校验放在这里而不是只放组合根:`voice-tts.json` 的 `baseUrl` 兜底同样要过这道
|
||||
// 闸,而那条路也是流进同一个 Config 的 —— 一处校验覆盖两个来源。
|
||||
if err := ValidateProbeURL(l.cfg.ProbeURL); err != nil {
|
||||
l.log.Warn("emotionTTS auto-launch disabled: invalid probe url",
|
||||
"url", l.cfg.ProbeURL, "err", err)
|
||||
l.cfg.Enabled = false
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// Config 返回**补齐缺省值之后**的配置(接线处据此如实记一条日志)。
|
||||
func (l *Launcher) Config() Config {
|
||||
if l == nil {
|
||||
return Config{}
|
||||
}
|
||||
return l.cfg
|
||||
}
|
||||
|
||||
// Trigger 投递一次「探活 → 必要时拉起」,**立即返回**。
|
||||
//
|
||||
// 返回值只回答「这一次有没有真的接单」(true = 派了后台任务;false = 没开 / 已在飞)。
|
||||
// 它**没有** error 通道:拉起失败在后台记 WARN,绝不回流到启动路径。
|
||||
func (l *Launcher) Trigger(ctx context.Context) bool {
|
||||
if l == nil || !l.cfg.Enabled {
|
||||
return false
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !l.begin() {
|
||||
return false
|
||||
}
|
||||
l.wg.Add(1)
|
||||
go func() {
|
||||
defer l.wg.Done()
|
||||
defer l.finish()
|
||||
l.Check(ctx)
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// Check 同步做一次检查(Trigger 的后台体;也供诊断/用例直接调用)。
|
||||
//
|
||||
// 它**不返回 error**:所有失败都是日志(WARN)与 Outcome 的一种。
|
||||
func (l *Launcher) Check(ctx context.Context) Outcome {
|
||||
if l == nil || !l.cfg.Enabled {
|
||||
return OutcomeDisabled
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
l.checkMu.Lock()
|
||||
defer l.checkMu.Unlock()
|
||||
if l.cooling() {
|
||||
l.log.Debug("emotionTTS launch attempt in cooldown, skipping",
|
||||
"url", l.cfg.ProbeURL, "cooldown", l.cfg.Cooldown)
|
||||
return OutcomeCooling
|
||||
}
|
||||
probeErr := l.probe(ctx)
|
||||
if probeErr == nil {
|
||||
l.log.Debug("emotionTTS already listening", "url", l.cfg.ProbeURL)
|
||||
return OutcomeAlreadyRunning
|
||||
}
|
||||
// 🔴 **调用方取消 ≠ 没起**(复审 P2-2a):后端关闭时 ctx 被取消,探活随之返回
|
||||
// `context.Canceled`;若把它当"没起",就会在**关闭过程中拉起 TTS**。这条必须是
|
||||
// 跳过 —— 不记 markAttempt、不拉、也不 WARN 成故障(正常关闭不是故障)。
|
||||
//
|
||||
// 判据只用 errors.Is,不按文案:探活**自身超时**是 context.DeadlineExceeded,
|
||||
// 那支仍然是"没起"(继续往下拉)。
|
||||
if errors.Is(probeErr, context.Canceled) {
|
||||
l.log.Debug("emotionTTS probe canceled by caller, skipping launch",
|
||||
"url", l.cfg.ProbeURL, "err", probeErr)
|
||||
return OutcomeCanceled
|
||||
}
|
||||
l.log.Debug("emotionTTS not listening", "url", l.cfg.ProbeURL, "err", probeErr)
|
||||
// 先记时刻再起进程:两个检查若都走到这里,冷却已经把后来者挡住(配合
|
||||
// checkMu,这一段的判定与记录是同一步)。
|
||||
l.markAttempt()
|
||||
if err := l.spawn(ctx); err != nil {
|
||||
l.log.Warn("emotionTTS launch failed, backend startup unaffected",
|
||||
"script", l.cfg.ScriptPath, "err", err)
|
||||
return OutcomeFailed
|
||||
}
|
||||
l.log.Info("emotionTTS launch script started",
|
||||
"script", l.cfg.ScriptPath, "url", l.cfg.ProbeURL)
|
||||
return OutcomeLaunched
|
||||
}
|
||||
|
||||
// Wait 等当前在飞的后台检查结束(关闭期/用例用;没有在飞任务时立即返回)。
|
||||
func (l *Launcher) Wait() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.wg.Wait()
|
||||
}
|
||||
|
||||
// begin 抢到"这一次由我检查"的资格;已在飞则拒绝。
|
||||
func (l *Launcher) begin() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.inflight {
|
||||
return false
|
||||
}
|
||||
if !l.lastAttempt.IsZero() && l.now().Sub(l.lastAttempt) < l.cfg.Cooldown {
|
||||
return false
|
||||
}
|
||||
l.inflight = true
|
||||
return true
|
||||
}
|
||||
|
||||
// finish 释放"在飞"标记(无论检查结果如何)。
|
||||
func (l *Launcher) finish() {
|
||||
l.mu.Lock()
|
||||
l.inflight = false
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// cooling 报告上一次拉起尝试是否还在冷却期内。
|
||||
func (l *Launcher) cooling() bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return !l.lastAttempt.IsZero() && l.now().Sub(l.lastAttempt) < l.cfg.Cooldown
|
||||
}
|
||||
|
||||
// markAttempt 记下一次拉起尝试的时刻(冷却的起点)。
|
||||
func (l *Launcher) markAttempt() {
|
||||
l.mu.Lock()
|
||||
l.lastAttempt = l.now()
|
||||
l.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// errDown 是"探活判定没起"的假错误(只要非 nil,口径就是"没起")。
|
||||
var errDown = errors.New("probe: connection refused")
|
||||
|
||||
// syncBuf 是并发安全的日志缓冲(日志由后台 goroutine 写,用例稍后读)。
|
||||
type syncBuf struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuf) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuf) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// captureLogger 返回一个把 DEBUG 也收下的日志器 + 它的缓冲。
|
||||
func captureLogger() (*slog.Logger, *syncBuf) {
|
||||
buf := &syncBuf{}
|
||||
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
|
||||
// 用例里的"已在跑/没起"都是注入的判据,因此不碰网络也不起进程。
|
||||
|
||||
// countingSpawn 返回一个"只数次数"的 spawn 实现。
|
||||
func countingSpawn(n *int32) spawnFunc {
|
||||
return func(context.Context) error {
|
||||
atomic.AddInt32(n, 1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheck_AlreadyRunning_NeverLaunches 钉住「已在跑 ⇒ 什么都不做」。
|
||||
//
|
||||
// 变异点:只要探活成功仍去 spawn(例如把探活结果当"警告"继续走),本用例立刻红。
|
||||
func TestCheck_AlreadyRunning_NeverLaunches(t *testing.T) {
|
||||
var spawned int32
|
||||
l := New(Config{Enabled: true},
|
||||
WithProbe(func(context.Context) error { return nil }),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
if got := l.Check(context.Background()); got != OutcomeAlreadyRunning {
|
||||
t.Fatalf("Outcome = %v, want already_running", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&spawned); got != 0 {
|
||||
t.Fatalf("已在跑仍拉起了 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheck_NotRunning_LaunchesOnce 钉住「没在跑 ⇒ 拉一次」。
|
||||
func TestCheck_NotRunning_LaunchesOnce(t *testing.T) {
|
||||
var spawned int32
|
||||
l := New(Config{Enabled: true},
|
||||
WithProbe(func(context.Context) error { return errDown }),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||||
t.Fatalf("Outcome = %v, want launched", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&spawned); got != 1 {
|
||||
t.Fatalf("拉起 %d 次, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrigger_ConcurrentTriggers_LaunchOnce 钉住「同进程内并发触发只拉一个」。
|
||||
//
|
||||
// 探活被卡住(release 之前不返回)以拉宽竞争窗口:10 个并发 Trigger 里应当
|
||||
// 只有**一个**接到单,最终只 spawn 一次。
|
||||
//
|
||||
// 变异点:去掉 inflight 守卫 ⇒ accepted 变成 10;去掉冷却/checkMu ⇒ spawned 可能 >1。
|
||||
func TestTrigger_ConcurrentTriggers_LaunchOnce(t *testing.T) {
|
||||
var spawned, accepted int32
|
||||
release := make(chan struct{})
|
||||
l := New(Config{Enabled: true},
|
||||
WithProbe(func(context.Context) error {
|
||||
<-release // 探活挂住,让 10 个 Trigger 都碰到 inflight 窗口
|
||||
return errDown
|
||||
}),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if l.Trigger(context.Background()) {
|
||||
atomic.AddInt32(&accepted, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 给并发触发足够时间撞上 inflight;这一步只影响用例的"严格度",不影响结论。
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
wg.Wait()
|
||||
l.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&accepted); got != 1 {
|
||||
t.Fatalf("接单 %d 次, want 1(并发触发只该有一个派后台任务)", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&spawned); got != 1 {
|
||||
t.Fatalf("拉起 %d 次, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheck_CooldownSuppressesRepeat 钉住「一次尝试之后的冷却」:后端重启风暴里
|
||||
// 不会每次都去拉一个正在启动中的 TTS。
|
||||
func TestCheck_CooldownSuppressesRepeat(t *testing.T) {
|
||||
var spawned int32
|
||||
now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
|
||||
clock := func() time.Time { return now }
|
||||
l := New(Config{Enabled: true, Cooldown: time.Minute},
|
||||
WithProbe(func(context.Context) error { return errDown }),
|
||||
WithSpawn(countingSpawn(&spawned)),
|
||||
WithClock(clock))
|
||||
|
||||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||||
t.Fatalf("第一次: Outcome = %v, want launched", got)
|
||||
}
|
||||
now = now.Add(30 * time.Second)
|
||||
if got := l.Check(context.Background()); got != OutcomeCooling {
|
||||
t.Fatalf("冷却期内: Outcome = %v, want cooling", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&spawned); got != 1 {
|
||||
t.Fatalf("冷却期内又拉起: %d 次", got)
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
if got := l.Check(context.Background()); got != OutcomeLaunched {
|
||||
t.Fatalf("冷却过后: Outcome = %v, want launched", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&spawned); got != 2 {
|
||||
t.Fatalf("冷却过后拉起 %d 次, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrigger_MissingScript_WarnsAndNeverPropagates 钉住「拉不起来时后端启动不受
|
||||
// 影响」:脚本不存在 ⇒ 一条带 source-ish 字段的 WARN,**没有** error 通道、没有
|
||||
// panic,且这次失败同样进入冷却(重启风暴不会变成重试风暴)。
|
||||
func TestTrigger_MissingScript_WarnsAndNeverPropagates(t *testing.T) {
|
||||
log, buf := captureLogger()
|
||||
missing := filepath.Join(t.TempDir(), "不存在的一键启动.bat")
|
||||
l := New(Config{Enabled: true, ScriptPath: missing}, WithLogger(log),
|
||||
// 探活也注入:本用例要验的是"拉的失败",不能因为本机真跑着 TTS 就走成
|
||||
// already_running。
|
||||
WithProbe(func(context.Context) error { return errDown }))
|
||||
|
||||
if !l.Trigger(context.Background()) {
|
||||
t.Fatal("启用时 Trigger 应接单")
|
||||
}
|
||||
l.Wait()
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "level=WARN") {
|
||||
t.Fatalf("拉不起来没记 WARN: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "backend startup unaffected") {
|
||||
t.Fatalf("WARN 没说明后端启动不受影响: %s", out)
|
||||
}
|
||||
if got := l.Check(context.Background()); got != OutcomeCooling {
|
||||
t.Fatalf("失败之后仍不冷却: Outcome = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrigger_ReturnsImmediately 钉住「探活/拉起不占调用方时长」:探活要 1 秒,
|
||||
// Trigger 必须在毫秒级返回(同步实现会红)。
|
||||
func TestTrigger_ReturnsImmediately(t *testing.T) {
|
||||
l := New(Config{Enabled: true},
|
||||
WithProbe(func(context.Context) error {
|
||||
time.Sleep(1 * time.Second)
|
||||
return errDown
|
||||
}),
|
||||
WithSpawn(func(context.Context) error { return nil }))
|
||||
|
||||
start := time.Now()
|
||||
if !l.Trigger(context.Background()) {
|
||||
t.Fatal("启用时 Trigger 应接单")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("Trigger 阻塞了 %v(应在毫秒级返回)", elapsed)
|
||||
}
|
||||
l.Wait()
|
||||
}
|
||||
|
||||
// TestTrigger_Disabled_DoesNothing 钉住「默认关」:一次探活都不发、一次都不拉。
|
||||
func TestTrigger_Disabled_DoesNothing(t *testing.T) {
|
||||
var spawned int32
|
||||
l := New(Config{}, // 零值 = 默认关
|
||||
WithProbe(func(context.Context) error {
|
||||
t.Error("未启用时不该探活")
|
||||
return nil
|
||||
}),
|
||||
WithSpawn(countingSpawn(&spawned)))
|
||||
|
||||
if l.Trigger(context.Background()) {
|
||||
t.Fatal("未启用时 Trigger 不该接单")
|
||||
}
|
||||
l.Wait()
|
||||
if got := atomic.LoadInt32(&spawned); got != 0 {
|
||||
t.Fatalf("未启用却拉起了 %d 次", got)
|
||||
}
|
||||
if got := l.Check(context.Background()); got != OutcomeDisabled {
|
||||
t.Fatalf("Check = %v, want disabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfig_Normalized 钉住缺省值的唯一裁决处:零值 ⇒ 主人的脚本 + 回环地址 +
|
||||
// 毫秒级探活 + 一段冷却。
|
||||
func TestConfig_Normalized(t *testing.T) {
|
||||
got := Config{}.Normalized()
|
||||
if got.Enabled {
|
||||
t.Fatal("零值必须默认关")
|
||||
}
|
||||
if got.ScriptPath != DefaultScriptPath {
|
||||
t.Fatalf("ScriptPath = %q", got.ScriptPath)
|
||||
}
|
||||
if got.ProbeURL != DefaultProbeURL {
|
||||
t.Fatalf("ProbeURL = %q", got.ProbeURL)
|
||||
}
|
||||
if got.ProbeTimeout != DefaultProbeTimeout || got.Cooldown != DefaultCooldown {
|
||||
t.Fatalf("超时/冷却没补默认值: %+v", got)
|
||||
}
|
||||
if !strings.Contains(DefaultScriptPath, "一键启动.bat") {
|
||||
t.Fatalf("默认脚本路径不对: %q", DefaultScriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutcome_String 让日志读得懂(写错就是"日志说 cooling 实际在拉")。
|
||||
func TestOutcome_String(t *testing.T) {
|
||||
want := map[Outcome]string{
|
||||
OutcomeDisabled: "disabled",
|
||||
OutcomeAlreadyRunning: "already_running",
|
||||
OutcomeLaunched: "launched",
|
||||
OutcomeCooling: "cooling",
|
||||
OutcomeFailed: "failed",
|
||||
OutcomeCanceled: "canceled",
|
||||
}
|
||||
for o, w := range want {
|
||||
if o.String() != w {
|
||||
t.Fatalf("%d.String() = %q, want %q", int(o), o.String(), w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// probeBodyLimit 是探活响应的最大读取量:探活只关心"有没有人应答",回应体一律丢弃,
|
||||
// 但也要有界(别被一个话痨服务的无限流拖住)。
|
||||
const probeBodyLimit = 4 << 10
|
||||
|
||||
// httpProbe 用一次 HTTP GET 回答「这个端口上已经有人在应答吗」。
|
||||
//
|
||||
// 判定口径(刻意宽松):**任何** HTTP 回应都算"在跑",只有传输层错误(连不上 /
|
||||
// 超时 / 连接被拒)才算"没起"。理由是这个服务的根路径回什么状态码并不稳定
|
||||
// (404/405/200 都可能),而"端口有人说话"已经足以证明不该再拉第二个实例 ——
|
||||
// 误判成"没起"的代价是去起一个抢同一个端口的进程,代价明显更大。
|
||||
//
|
||||
// `Proxy: nil` 的实际作用范围(复审记录项 1:别高估它):net/http 本来就不给
|
||||
// **回环 IP** 走代理,所以探 `127.0.0.1` 时这行可有可无(去掉再设 HTTP_PROXY,
|
||||
// 用例照样全绿)。它真正保护的是 `probeUrl` 被配成**主机名**(如
|
||||
// `http://tts.local:9880`)的情况:那种地址会被环境里的 HTTP_PROXY 接管,从而
|
||||
// 得到"有人在跑"的假结论。要覆盖这一点得用主机名 probeUrl 写用例。
|
||||
func httpProbe(url string, timeout time.Duration) probeFunc {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
}
|
||||
return func(ctx context.Context) error {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ttslaunch: build probe request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ttslaunch: probe %s: %w", url, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeBodyLimit))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateProbeURL 校验探活地址的形状:必须是**绝对 http(s) URL 且带主机**。
|
||||
//
|
||||
// 为什么值得单独一道闸(复审 P2-2b):探活地址写错(`127.0.0.1:9880` 漏 scheme、
|
||||
// 拼错主机)不是"报个错就完了" —— 它会让探活**每次都失败**,于是"没起"永远成立,
|
||||
// 后端每启动一次就拉一次进程。所以调用方应当把非法地址当作"不启用"。
|
||||
func ValidateProbeURL(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
u, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ttslaunch: parse probe url %q: %w", raw, err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("ttslaunch: probe url %q is not an absolute http(s) url", raw)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("ttslaunch: probe url %q has no host", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardLogger 是没注入日志器时的兜底(绝不 nil 解引用)。
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHTTPProbe_AnyResponseMeansRunning 钉住探活判据:**任何** HTTP 回应都算在跑
|
||||
// (这个服务的根路径回什么状态码不稳定,只要有人在应答就不该再拉第二个实例)。
|
||||
func TestHTTPProbe_AnyResponseMeansRunning(t *testing.T) {
|
||||
for _, code := range []int{http.StatusOK, http.StatusNotFound, http.StatusMethodNotAllowed} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
if err := httpProbe(srv.URL, time.Second)(context.Background()); err != nil {
|
||||
t.Fatalf("状态码 %d 被判成没起: %v", code, err)
|
||||
}
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPProbe_Unreachable 钉住"连不上 ⇒ 没起":先占一个端口再放掉,探它必然被拒。
|
||||
func TestHTTPProbe_Unreachable(t *testing.T) {
|
||||
addr := closedAddr(t)
|
||||
if err := httpProbe("http://"+addr, 500*time.Millisecond)(context.Background()); err == nil {
|
||||
t.Fatal("没人监听的端口被判成在跑")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPProbe_TimeoutIsHonored 钉住"短超时"确实生效:服务故意不回话,
|
||||
// 100ms 的探活必须按超时失败,而不是等到服务自己醒。
|
||||
func TestHTTPProbe_TimeoutIsHonored(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { <-block }))
|
||||
defer func() {
|
||||
close(block)
|
||||
srv.Close()
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
err := httpProbe(srv.URL, 100*time.Millisecond)(context.Background())
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("不回话的服务被判成在跑")
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("探活等了 %v, 明显没按 100ms 超时", elapsed)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "probe") {
|
||||
t.Fatalf("错误没标来源: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// closedAddr 返回一个"刚被放掉"的回环地址(探它 = 连接被拒)。
|
||||
func closedAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
if err := ln.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows
|
||||
|
||||
package ttslaunch
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// shellCommand 在非 Windows 上按 shell 脚本解释(emotionTTS 的一键启动脚本只在
|
||||
// Windows 上有;这里的实现只为让本包在 darwin/linux 上照常编译,实际拉起会因
|
||||
// 路径不存在而落到一条 WARN)。
|
||||
func shellCommand(script string) *exec.Cmd {
|
||||
return exec.Command("/bin/sh", script)
|
||||
}
|
||||
|
||||
// startDetached 起进程(非 Windows 上不需要额外的进程组标志)。
|
||||
func startDetached(cmd *exec.Cmd) error { return cmd.Start() }
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const (
|
||||
// createNewProcessGroup 让子进程成为**新进程组**的组长:后端控制台里的 Ctrl+C
|
||||
// 只送给后端自己的进程组,不会连带掐掉 TTS。
|
||||
createNewProcessGroup = 0x00000200
|
||||
// createNewConsole 给子进程开**自己的**控制台窗口 —— 与主人「双击一键启动.bat」
|
||||
// 看到的形态一致(TTS 的日志照常可见,不糊进后端日志),也不受后端控制台关闭影响。
|
||||
createNewConsole = 0x00000010
|
||||
)
|
||||
|
||||
// shellCommand 构造「解释一个批处理文件」的命令。
|
||||
//
|
||||
// 为什么必须经 cmd.exe:`.bat` 不是 PE 可执行映像,Windows 内核无法直接
|
||||
// CreateProcess 起来(要么失败,要么依赖某个 Go 版本碰巧的兼容路径)。这里把脚本
|
||||
// 路径作为**独立参数**交给 exec.Command —— 绝不自己拼命令行字符串,空格/中文路径
|
||||
// 的引号由 os/exec 按 Windows 规则处理(主人那个路径含中文,也见过含空格的安装路径)。
|
||||
func shellCommand(script string) *exec.Cmd {
|
||||
return exec.Command("cmd.exe", "/c", script)
|
||||
}
|
||||
|
||||
// startDetached 以"脱离后端生命周期"的方式起进程(不接管它的输入输出)。
|
||||
func startDetached(cmd *exec.Cmd) error {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: createNewProcessGroup | createNewConsole,
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scriptSpawner 把"起那个脚本"包成 spawnFunc(缺省实现)。
|
||||
func scriptSpawner(path string) spawnFunc {
|
||||
return func(ctx context.Context) error {
|
||||
return LaunchScript(ctx, path)
|
||||
}
|
||||
}
|
||||
|
||||
// LaunchScript 起一个启动脚本并**立即返回**,不等它结束。
|
||||
//
|
||||
// 细节与代价:
|
||||
// - 脚本不存在 / 是目录 ⇒ 返回错误(调用方只 WARN:这台机器上没装 emotionTTS
|
||||
// 是正常形态,不是后端的病)。
|
||||
// - `cmd.Dir` 设为脚本所在目录:`一键启动.bat` 里的相对路径按它自己的习惯解析,
|
||||
// 而不是按后端的进程工作目录。
|
||||
// - **刻意不使用 ctx**:`exec.CommandContext` 会在 ctx 取消时杀掉子进程,而后端
|
||||
// 优雅退出**不该带走** TTS —— 服务是独立的,主人的后端重启与它是两件事。
|
||||
// - 起完由后台 goroutine `Wait` 回收(脚本通常活到 TTS 退出为止,这个 goroutine
|
||||
// 跟着后端一起活,代价可忽略)。
|
||||
func LaunchScript(_ context.Context, path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("ttslaunch: empty script path")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ttslaunch: script %s: %w", path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("ttslaunch: script %s is a directory", path)
|
||||
}
|
||||
cmd := shellCommand(path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
if err := startDetached(cmd); err != nil {
|
||||
return fmt.Errorf("ttslaunch: start %s: %w", path, err)
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ttslaunch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLaunchScript_MissingFile 钉住"脚本不存在 ⇒ 返回可读错误"(调用方只 WARN)。
|
||||
func TestLaunchScript_MissingFile(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "一键启动.bat")
|
||||
err := LaunchScript(context.Background(), missing)
|
||||
if err == nil {
|
||||
t.Fatal("脚本不存在却没报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "一键启动.bat") {
|
||||
t.Fatalf("错误里没有脚本路径: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchScript_Directory 钉住"路径是目录 ⇒ 报错"(配置写错时别去 cmd /c 一个目录)。
|
||||
func TestLaunchScript_Directory(t *testing.T) {
|
||||
if err := LaunchScript(context.Background(), t.TempDir()); err == nil {
|
||||
t.Fatal("目录被当成脚本起起来了")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchScript_EmptyPath 钉住空路径不会退化成"起当前目录里的东西"。
|
||||
func TestLaunchScript_EmptyPath(t *testing.T) {
|
||||
if err := LaunchScript(context.Background(), " "); err == nil {
|
||||
t.Fatal("空路径没报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLaunchScript_StartsBatchFile 是本切片唯一真起进程的用例(只在 Windows 上有意义):
|
||||
// 它同时钉住三件容易踩的事 ——
|
||||
//
|
||||
// 1. `.bat` 必须经解释器起得来(裸 CreateProcess 不行);
|
||||
// 2. 路径含**中文**(主人的 `一键启动.bat` 就是这个形状)时编码/引号不出岔子;
|
||||
// 3. 工作目录按脚本所在目录解析(脚本里的相对路径是相对**它自己**)。
|
||||
func TestLaunchScript_StartsBatchFile(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("一键启动.bat 只在 Windows 上有意义")
|
||||
}
|
||||
// 不用 t.TempDir:脚本进程的**工作目录**就是脚本所在目录,Windows 在它退出前
|
||||
// 不允许删那个目录,t.TempDir 的自动清理会误报失败。这里自己建、自己尽力清。
|
||||
base, err := os.MkdirTemp("", "ttslaunch-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdtemp: %v", err)
|
||||
}
|
||||
defer cleanupEventually(t, base)
|
||||
dir := filepath.Join(base, "emotionTTS_语音 服务")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
script := filepath.Join(dir, "一键启动.bat")
|
||||
const body = "@echo off\r\necho started> \"%~dp0marker.txt\"\r\n"
|
||||
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write script: %v", err)
|
||||
}
|
||||
|
||||
if err := LaunchScript(context.Background(), script); err != nil {
|
||||
t.Fatalf("起脚本失败: %v", err)
|
||||
}
|
||||
waitForFile(t, filepath.Join(dir, "marker.txt"), 10*time.Second)
|
||||
}
|
||||
|
||||
// cleanupEventually 尽力删掉临时目录:给脚本进程一点退出时间,删不掉只记日志
|
||||
// (绝不让清理动作把"脚本确实起来了"这个结论打成失败)。
|
||||
func cleanupEventually(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
for i := 0; i < 60; i++ {
|
||||
if err := os.RemoveAll(dir); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Logf("临时目录还没删掉(脚本进程可能仍在运行): %s", dir)
|
||||
}
|
||||
|
||||
// waitForFile 等一个文件出现(轮询,带 deadline)。
|
||||
func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("等 %v 也没等到 %s(脚本没跑起来)", timeout, path)
|
||||
}
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user