5 Commits
Author SHA1 Message Date
AskaEth c2e413ec38 feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
2026-10-07 09:34:47 +08:00
AskaEth 20f82c9679 docs(contract): v0.82 —— resultSummary(失败也带)+ 自排成本可见面 + browser 面板(Z4)
- tool_execution.end 的 resultSummary(含失败口径、≤500 rune、空 ⇒ 缺席)
- GET /admin/backthink 加 ?trigger=(闭集 + 未知/空 400 invalid_trigger + 下推 SQL)+ 新端点
  GET /api/v1/admin/backthink/self-wake(writeAuth、三态、gate 快照、*IsDefault 语义)
- GET /admin/panels 新增 browser/settings + 六个动作(3 读 3 写)+ 错误码 + turnAttachment
- request_think 可达性改正(已上线);计数 72→73(writeAuth 18→19)
2026-10-07 09:34:46 +08:00
AskaEth 9fa7e65278 feat(loop): tool_execution.end 失败也带 resultSummary(Z3,主会话裁定)
与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText),
缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏)
2026-10-07 09:34:45 +08:00
AskaEth 8e2441c183 feat(browser): 补上 B3/B4 的两行接线(Z2)—— 面板页与截图进上下文真生效
- wire_runtime.go 加 TransformContext: a.browserShotTransform(visionResolver)(只改本轮所见)
- wire_frontend.go 在 declareBuiltinPanels 之后接 wireBrowserPanel(带 a.cfg != nil 守卫,
  否则 6 处既有夹具 panic;生产路径 cfg 恒非 nil ⇒ 行为与字面写法一致)
- 3 条盯梢用例(1 条 Skip→转正 + 2 条新增),2 变异打红
- 真机(隔离实例):panels 出现 browser/settings、三个只读动作真回执、**真回合内联 image 部件
  61,410 字符** ⇒ design/28 判据 2 在真产物里成立
2026-10-07 09:34:44 +08:00
AskaEth 93fc24cade docs(readme): 用户侧文档按代码重写(Z1)
修正 6 条「本该 ✅ 却标 ⚠️/❌」的能力(checkpoint 恢复/DashScope ASR/异步记忆提取/知识库删除/
全链路 P2/面板 18 模块)+ 快速开始与 .env 冻结口径 + 目录结构 + **新增「已知缺口与路线图」如实列未做**
2026-10-07 09:34:44 +08:00
14 changed files with 1448 additions and 220 deletions

No files matched your search

+191 -26
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约
> **契约版本:`v0.81`**(上一版 `v0.80`,其条目见 §6;`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.82`**(上一版 `v0.81`,其条目见 §6;`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -22,7 +22,9 @@
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**(浏览器是**模型面工具**,不进客户端契约面)。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。
> **v0.82 是三面增量版(① 已实现(切片 Y1 `215eedf`);② 已实现(切片 Y3 `4213b26`);③ 形状已定、代码在树(切片 Y4 `e0a6bca` + 组合根挂点);**纯增量、无破坏性变更**)**:① **`tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)** —— 工具卡从此能在**实时**显示「这次调用拿到了什么」,不必等事后翻回看面。它修的是「实时只看到工具名与状态、结果要事后才知道」。🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态** —— **内容与回看面 `toolCalls[].resultSummary` 同源同口径**(同一份**已脱敏**文本 `trace.RedactText`:凭据形状清洗 + 截断)、**同一个上限 `≤500` rune**(超长按 rune 截断、末位 `…`);**空值 ⇒ 整键缺席,绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空;`tool_execution.start` / `.update` **不带**该键。⚠️ **失败也带**(本版口径):调用失败 / 取消 / 超时的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与回看面(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。详见 **§3.4.2**。② **自排的成本可见面**(切片 Y3,design/12 §N C.5/C.6)—— `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(取值域 = **闭集**:三个内建标签 + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`;值**未知或为空** ⇒ **`400 invalid_trigger`**,**绝不静默忽略** —— 静默忽略会让面板在显示全部的同时声称「正在筛选」;合法值**下推 SQL**,因此 `limit` 数的是**匹配**条数);新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e),**三态** `available`/`running`/`reason` + `gate` 快照 + `configFile`)。⚠️ **`trigger` 的「显示口径」仍是开放的**(客户端按字面值显示、不做闭集校验)—— **筛选白名单**与**显示取值域**是两件事,别混(见 §4.10(d))。③ **`browser` 插件管理面板(B4)**(切片 Y4,design/28 §8)—— `GET /api/v1/admin/panels` 的面板目录新增 `browser` / `settings`(「浏览器与截图」,4 个区块:配置表单 + 状态 / 指纹 / 截图三张只读表),该插件声明**六个动作**(3 读 3 写,见 §4.14(d));**不开任何新路由**(走既有 A14 通用动作分发),因此 §2.0 计数**只**因 ② 的 self-wake 端点 +1。**模型面**(非客户端契约面)另有一条:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`)。**④ 本版把 §4.10(d) 一句已成假话的口径改正**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`;启动日志 `self wake tool registered tool=request_think`)⇒ `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**在生产里会真的出现**。**⑤ 计数**:`writeAuth` 段 18 → **19**、管理路由总数 72 → **73**(`14 + 19 + 40`)。⚠️ **登记时的工作树状态(如实)**:③ 的**组合根两个挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)与 ① 的**失败也带摘要**(`internal/runtime/loop/tool_frame_summary.go`)在本版登记时**仍在工作树、未提交** ⇒ 提交前,跑着的后端里**没有**这些行为;提交后请按 `git show` 逐字复核。
> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。—— ✅ **本条已过期,已由 v0.82 ④ 改正**:`request_think` 已接线(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`),生产里**会真的出现**。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面(`GET /admin/panels` 多一条目录项 + 六个动作走既有 A14),见 **§4.14(d)**。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。
> **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**。
@@ -144,10 +146,10 @@
| 档位 | 中间件 | 条数 | 说明 |
|---|:--:|:--:|---|
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
| **owner/admin 读档** | `writeAuth`(owner/admin) | **18** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、**链路图时间轴(v0.80,B18)** |
| **owner/admin 读档** | `writeAuth`(owner/admin) | **19** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、**自排闸只读快照(v0.82,B19)** |
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
**管理路由总数 = 14 + 18 + 40 = 72 条。**
**管理路由总数 = 14 + 19 + 40 = 73 条。**
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
@@ -158,7 +160,9 @@
> ✅ **记账滞后已修正(2026-10-06,本版回补)**:按上一段「计数口径」那句(= 三张清单**列出的行数**)核,**§2.2 实有 18 行**(B1–B18)、**§2.1 实有 14 行**(A1–A14)、**§2.3 实有 40 行** ⇒ 逐行相加 **14 + 18 + 40 = 72**,与上表「条数」序列(`writeAuth` **18** / 总数 **72**)**已一致**(`routes.go` 交叉核对:admin 侧经 `writeAuth` 挂载 20 条,减去 §2.4(a) 按历史口径不计入本表的 `channels/events` 与 `.../counters` ⇒ **18**)。**根因**(S6 报告 §6.1 发现):v0.76 新增 B15 时**漏把条数 +1**,此后 v0.78 / v0.79 / v0.80 各版沿旧序列 +1 而未回补 ⇒ 那个 **−1 滞后从 v0.76 一路带到 v0.80 的登记值**。
> ⚠️ **上面 v0.78 / v0.79 两个计数段的绝对值仍沿用当时未回补的写法**(其**增量正确** —— `viewerAuth` 12 → 14、`writeAuth` 各版 +1 / +2 —— 但**绝对值比真实行数少 1**);**§6 里各版条目(v0.76 ~ v0.80)的同类计数同理**。按本文件「历史条目保留当时状态」的惯例**不逐版改数**,**以本表 + 三张清单的行数为准**。
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上面的 **18**。逐条以代码为准。
> **v0.82 计数变更(实数,已按 `routes.go` 核对)**:`writeAuth` 段新增一条(**B19 `GET /admin/backthink/self-wake`**,切片 Y3)⇒ 段内 **18 → 19**、总数 **72 → 73**。三张清单的行数同步为 **§2.1 14 行**(A1–A14)/ **§2.2 19 行**(B1–B19)/ **§2.3 40 行**(C1–C40)。**核对方式(可复现)**:`internal/services/gateway/routes.go` 里经 `writeAuth` 挂载的 **admin** 路由**实有 21 条**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **19**,与 §2.2 的行数一致;`viewerAuth` 挂载 **14**、`ownerAuth` 挂载 **40**(逐条列举见 `grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*<middleware>'`)。⚠️ **本版另一个「看似该 +1、其实不动计数」的面**:`browser` 插件的六个动作(§4.14(d))**不开任何新路由** —— 它走**既有** A14 `POST /admin/plugins/{id}/actions/{action}`,因此 §2.1 的 14 与总数都**不变**(A14 那一行本就写明「写门落在 handler 内」,动作名由**声明**白名单决定,声明多一个插件不新增路由)。
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上表的 **writeAuth 条数**(v0.82 为 **19**)。逐条以代码为准。
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
@@ -178,12 +182,12 @@
| A10 | GET | `/api/v1/admin/observability/trace` | 运行时 span |
| A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) |
| A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) |
| A13 | GET | `/api/v1/admin/panels` | **插件管理面板接入点快照(v0.78 新增,切片 PLG-2)**:**按调用者角色过滤**后的导航条目集合 —— viewer 只拿到只读区块(`status`/`log`/`table`,且 table 的行内动作被摘空、`form` 整体不出现、区块全空的面板整条不出现)。响应 `{data:{role,groups[],pluginIds[]}}`。未接面板中枢 → **`501 panels_unavailable`**(**绝不回空列表**)。形状与两条给客户端的口径见 §4.14(a) |
| A13 | GET | `/api/v1/admin/panels` | **插件管理面板接入点快照(v0.78 新增,切片 PLG-2)**:**按调用者角色过滤**后的导航条目集合 —— viewer 只拿到只读区块(`status`/`log`/`table`,且 table 的行内动作被摘空、`form` 整体不出现、区块全空的面板整条不出现)。响应 `{data:{role,groups[],pluginIds[]}}`。未接面板中枢 → **`501 panels_unavailable`**(**绝不回空列表**)。形状与两条给客户端的口径见 §4.14(a)。**v0.82 起目录里多一条内置页**:`browser` / `settings`(「浏览器与截图」,切片 Y4,见 §4.14(d))⇒ 声明接线后 `pluginIds` 为 `["browser","web-search"]`(**已排序**)—— ⚠️ **登记时该挂点仍在工作树、未提交**,因此**在跑的那份产物里没有它**(真机已核:`:10942` 上 `GET /admin/panels` 需 owner 凭据,但**启动日志全篇 0 条 `browser panel declared`** ⇒ 该面板未接线)|
| A14 | POST | `/api/v1/admin/plugins/{id}/actions/{action}` | **插件动作分发(v0.78 新增,切片 PLG-2)**。⚠️ **读写面不同档**:路由走 `viewerAuth`,但**写动作在 handler 内按 owner/admin 收紧**(`form.submit` / `table.rowActions` 是写;`table`/`log` 的 `source` 与下拉 `optionsFrom` 是读,对 viewer 开放)。**只接受该插件声明过的动作名**(未声明 ⇒ `404 action_not_declared`,**不是**「没权限」)。请求体可选 JSON(空体 ⇒ `payload=null`);**宿主不接受任何来自声明的请求地址**(不给插件开 SSRF 面)。✅ **v0.79:生产执行器已接线**(切片 L1/PLG-2)—— 内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` 都已登记生产处理器,**真可用**(真读写可变源、探针真发一次 HTTP);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。错误码与响应形状见 §4.14(b)(c) |
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
### 2.2 owner/admin 读(18 条,`writeAuth`)
### 2.2 owner/admin 读(19 条,`writeAuth`)
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
@@ -194,7 +198,7 @@
| B5 | GET | `/api/v1/admin/llm/usage` | LLM 用量汇总(切片 U1,v0.64)。`?since=&until=&purpose=`;缺省近 24h、跨度上限 90 天(超 → `400`);`hitRate = cacheHit/(cacheHit+cacheMiss)`,分母 0 ⇒ `0`。**不含凭据与消息正文**。未接线 → `500 usage_unavailable` |
| B6 | GET | `/api/v1/admin/llm/catalog` | 模型目录候选(`?refresh=1` 强制在线刷新)。既无快照又抓不到 → `503 catalog_unavailable` |
| B7 | GET | `/api/v1/admin/users` | 账号/API key 清单 |
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**默认是精确档位**,带 `urgencyLowerBound:true` 时才是下限,见 §4.10(b))与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增;档位的精确语义与 `urgencyLowerBound` 为 v0.70**。⚠️ 每条的 `trigger` 是**开放的**取值域(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮的**事件主题** / **`self_wake`(v0.81 登记)**)= 她自己排起的一轮思考),客户端**按字面值显示**、不做闭集校验,见 §4.10(d) |
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**默认是精确档位**,带 `urgencyLowerBound:true` 时才是下限,见 §4.10(b))与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增;档位的精确语义与 `urgencyLowerBound` 为 v0.70**。⚠️ 每条的 `trigger` 是**开放的**取值域(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮的**事件主题** / **`self_wake`(v0.81 登记)** = 她自己排起的一轮思考),客户端**按字面值显示**、不做闭集校验,见 §4.10(d)。🆕 **v0.82 新增可选 `?trigger=` 服务端筛选**(切片 Y3):取值域 = **闭集**(= 上面那些取值,真源 `backthink.TriggerFilterValues()`);**未知值或空值 ⇒ `400 invalid_trigger`**(**绝不静默忽略**);合法值**下推 SQL**,`limit` 数的是**匹配**条数。⚠️ **「筛选白名单是闭集」与「显示取值域是开放集」不矛盾**,见 §4.10(d)(e) |
| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 |
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
@@ -204,8 +208,8 @@
| B15 | GET | `/api/v1/admin/memories/tidy/dry-run/{runId}` | **异步试算结果查询(v0.76 新增 · ✅ 已实现(v0.77 校正))**:配合 `POST …/memories/tidy/dry-run?async=1`(后者仍属 B14,`async=1` 时回 `202` + `{data:{runId,status:"running",startedAt}}`)轮询取回结果。响应 `{data:{status:"running"|"done"|"failed", result?, error?}}`:**`running` 时 `result`/`error` 两键都缺席**(绝不回一个空 `result` 冒充「跑完了但没有建议」);`done` 时 `result` 与 §4.12 同步响应的 `data` **逐字节同形**(**同一套 JSON,不是第二份**);`failed` 时 `error` 为 `{code,message}`(`code` 沿用 §4.12 的失败码)。⚠️ **结果只活在进程内存**(单二进制、零跨进程):TTL **30 分钟**(从**完成时刻**起算)、同时只允许 **1** 个在跑(**第二个请求复用既有 `runId`**,不引入新错误码);进程重启后旧 `runId` **如实失效** ⇒ `404 tidy_run_not_found`,**绝不假装还在跑**。鉴权与 B14 同档(`writeAuth` = owner/admin)、同样**不审计**。语义见 §4.12.1 |
| B16 | GET | `/api/v1/admin/memories/reviews` | **入库前审查(D3)留痕列表(v0.79 新增,切片 L45;✅ 已实现)**。`?limit=`:缺省 **100**、`≤0` 同缺省、> **500** 钳到 500、非整数 → `400 bad_request`(钳位不报错,与渠道事件查询面同一约定)。响应 `{data:[…]}`,元素键 camelCase:`id` / `itemId` / `personaId` / `scope` / `projectId`(可选)/ `decision` ∈ `skip\|merge\|modify\|create` / `reason`(可选,规则依据)/ `veto`(可选,命中的否决规则)/ `targetId`(可选,命中的既有条目)/ `mergedIds`(**恒为数组**)/ `score` / `recallMode`(可选)/ `degraded` / `ruleVersion` / `sessionId`(可选)/ `createdAt`(**UTC RFC3339**)。🔴 **不含记忆正文**(命中的 id 由客户端回表取原文)。未接线 → `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕);读取失败 → `500 reviews_failed`。只读、不审计 |
| B17 | POST | `/api/v1/admin/memories/{id}/revert` | **撤销一次软合并(v0.79 新增,切片 L45;✅ 已实现;design/23 §8「回滚」)**。**无请求体**。200 `{data:{reverted:"<id>"}}`;该行不在 `merged` 状态(id 拼错 / 已回滚过)→ `404 not_found`;未接线 → `500 memory_review_unavailable`;存储失败 → `500 revert_failed`。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`,并从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1);**行永不物理删除**。**落审计** `memory.review.revert`(who / result ∈ `ok\|error`;**只记 item id,不记正文**)。⚠️ 门是 `writeAuth`(owner\|admin);`design/23 §11` 表格对同族 `candidates/{id}/revert` 标的是 owner-only —— **口径差异待主会话裁决**(若以设计为准,路由换成 `ownerAuth` 即可) |
| B18 | GET | `/api/v1/admin/flow/timeline` | **链路图时间轴 / 多轮串联(v0.80 新增,切片 W1;v0.81 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionBoundaryReached,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。🆕 **v0.81(切片 X1)**:① 新增 **`retentionBoundaryReached`**(bool)= 「该会话确有更早的轮次已被保留期清扫」——**与 `truncated` 分意**,只有它 `true` 时面板才可说「更早的轮次已过保留期」(判据/盲区见 **§4.11.1(a)**);② **`round` 跨重启不再重复**(发布器先从落盘播种计数器,见 **§4.11.1(e)**)⇒ 按 `round` 分组不再把重启前后两轮并成一块。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** |
| B19 | GET | `/api/v1/admin/backthink/self-wake` | **自排闸只读快照(v0.82 新增,切片 Y3;design/12 §N C.5 的读侧)**,只读、不审计、不写库。**无参数**。响应 `{data:{available,running,reason?,configFile?,gate?}}` —— **三态必须可区分**(面板**不得**把「看不到」画成「0 次」):① `available:false` ⇒ 本进程给不出快照(后台思考未接线 / 实现不提供读口 / 闸不在),此时 **`gate` 缺席**、`reason` 说明原因(**HTTP 仍是 `200`**,不假装有一个 0/0 的闸);② `available:true, running:false` ⇒ 已接线但不在运行,`gate` **仍带**配置面数字(上限/窗口/代码默认值),`reason` 说明「此刻她排不出新的一轮」;③ `available:true, running:true` ⇒ 正常态,`reason` 缺席。`gate` 快照 = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`(`used` 与工具收据 `SelfWakeReceipt.Used` **同一口径**;`limit`/`windowSec` 是**进程生效**值,已 normalize)。🔴 **`limitIsDefault` / `windowIsDefault` 的语义**:报告生效值**恰好等于**代码默认值 —— **不是**「配置文件里没写这个键」的断言(网关**不读** `backthink.json`,与 config-facts 的 `isDefault` 同一条诚实口径:无从区分「配了 4」与「没配」)。`configFile` = **相对落点**(`config/backthink.json`,相对 `$DATA_DIR`)。⚠️ **它不含敏感信息、也不是写口**:改上限/窗口要改配置文件 + 重启(面板写路径**未做**)。语义与三态口径见 **§4.10(e)**。✅ **真机已核(路由存在性,无需凭据)**:`:10942` 上 `POST /api/v1/admin/backthink/self-wake` → **`405` + `Allow: GET, HEAD`**、`GET` 同路径无 token → **`401`**(负对照:同法打一个不存在的 admin 路径 → `404`)⇒ 这条路由**确在**运行中的产物里(⚠️ **不代表**已用真凭据拿到过 `data`;后者**未验**) |
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
@@ -392,13 +396,26 @@
| `message.start` / `message.delta` / `message.end` | `{index, delta?, streamId?}` |
| `thinking.start` / `thinking.delta` / `thinking.end` | `{index, delta?, streamId?}` |
| `tool_call.start` / `tool_call.delta` / `tool_call.end` | `{id, index, delta?, streamId?}` |
| `tool_execution.start` / `tool_execution.update` / `tool_execution.end` | `{tool, phase, id?, delta?}`(`phase` ∈ `running/done/error`;**phase 绝不塞进 delta**) |
| `tool_execution.start` / `tool_execution.update` / `tool_execution.end` | `{tool, phase, id?, delta?, resultSummary?}`(`phase` ∈ `running/done/error`;**phase 绝不塞进 delta**;**`resultSummary` 只在 `end` 帧、且只在「真的有结论」时在场**(v0.82)—— **成功 ⇒ 结果摘要;失败/取消/超时 ⇒ 该次错误文本的摘要**(两边都走同一份脱敏路径);**只有「真的没有结果」才整键缺席**(成功但结果为空、或错误文本为空),**绝不用空串顶位**。上限 **≤500** rune(超长按 rune 截断、末位 `…`),与回看面 `toolCalls[].resultSummary` 同源同口径,读侧不做二次脱敏也不放宽) |
| `turn.start` / `turn.end` | 无 data(帧壳即边界信号) |
| `stream_chunk` | `{delta}`(非流式路径的单段文本) |
| `stream_end` | `{stopReason, steps?, error?}` |
> `streamId`(可选)= 同一次模型流(一次 ChatStream 调用)的多帧关联键;`turnId` = 整个回合恒定。两者分工不同。
> `tool_execution.update`:wire 映射已实现,但**工具分发器目前没有中途进度上报源**,故实际**暂无生产者**(`start`/`end` 真实发出)。
>
> **`resultSummary` 的缺席语义(v0.82,安卓端按此编程)**:它是 `tool_execution.end` 的**可选**键 —— **键在场 ⇔ 这次调用有了结论**;客户端**只做正向断言**,人缺席时显示「工具名 + 状态」即可。映射表(**客户端不要按 `phase` 反推键在不在**,只按「键在不在」分支):
| 情形 | `.end` 帧上的 `data.resultSummary` |
|---|---|
| 调用成功且结果非空 | **在场**(已脱敏 + 已截断;图侧同源文本通常 ≤80 rune,网关再按 ≤500 兜底) |
| 调用失败 / 取消 / 超时(`Invoke` 返回 error) | **在场** —— **该次错误文本的脱敏渲染**(与回看面「失败调用给图上的 `error` 摘要」同一说法) |
| 调用成功但结果为空(nil / 空串) | **缺席**(整键不出现) |
| 错误文本本身为空 | **缺席** |
| `tool_execution.start` / `.update`(即使有人误挂摘要) | **缺席**(契约只在 `end` 帧给) |
| 被静音的内部流程(backthink / 子代理) | **整帧不发**(既有行为,一字未改) |
> 「绝不回空串顶位」有两道保险:loop 侧留空 + 网关侧 `s != ""` 才写键(切片 Y1 的变异 A 证明这道守卫真的在起作用)。⚠️ **与回看面的差异(v0.82 起已收窄)**:v0.82 之前本帧**失败时整键缺席**、而回看面对失败调用给的是 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」。v0.82 把两侧统一为「失败也带该次错误文本的摘要」,**客户端从此只需一套判据**。⚠️ **未确证**:本键的**真机**(`:10942` 发布产物)端到端**未跑**,见 §7.4-30。
#### 3.4.3 编排帧(owner+admin)
@@ -487,17 +504,17 @@
| 状态 | code(示例) | 场景 |
|---|---|---|
| 400 | `bad_request`、`image_too_large`、`too_many_documents`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c)) |
| 400 | `bad_request`、`image_too_large`、`too_many_documents`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**)、`invalid_trigger`(**v0.82**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c));**`GET /admin/backthink?trigger=` 的值未知或为空**(**v0.82**,见 §4.10(e-1);`browser` 面板的**未知字段**也走 `invalid_payload`,见 §4.14(d)) |
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
| 403 | `forbidden`、`unknown_role`(**v0.78**,面板快照的角色无法识别,理论不可达、fail-closed) | 角色不足;主会话不可删;**插件写动作的调用者不是 owner/admin**(v0.78,见 §4.14(b)) |
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、v0.79 已实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b));**回滚的记忆条目不在被合并状态**(`not_found`,v0.79,见 §2.2 B17) |
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c)) |
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**)、`browser_unavailable` / `browser_session_not_open` / `config_rejected`(**v0.82**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c));**`browser` 面板(v0.82,见 §4.14(d))**:本机没有可用浏览器来源 / 当前没有打开的页面(面板**不替模型开页**)/ 这次保存没能生效**已按字节回滚** |
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
| 429 | `rate_limited` | 限流(框架级) |
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效) | 内部错误或依赖未接线(**绝不伪装成功**) |
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效;⚠️ **回滚本身失败**时也用这个码并明说「盘上可能已经变了」—— **v0.82 的 `browser` 面板复用这两个码**,见 §4.14(d)) | 内部错误或依赖未接线(**绝不伪装成功**) |
| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明,但宿主没有登记它的处理器** —— 🔴 **v0.79 起不再是兜底档**:内置 `web-search` 的动作已全部登记(见 §4.14(b)(c)),这条只在「声明写了却没实现」时出现 |
| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);后一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**),见 §4.14(b)(c) |
| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**)、`browser_action_failed`(**v0.82**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);中间一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**);末一个 = `browser` 面板的截图 / 指纹自检**执行失败**(**v0.82**,见 §4.14(b)(d)) |
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable` | 派发/上游不可用 |
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
@@ -823,8 +840,8 @@
#### (d) v0.81 新增:她自排轮(`self_wake`)的用户可见面
> 依据 `design/12 §N C`(自排:结构化 self-initiated 触发 + 她可调用的「自排一次思考」工具 + 每窗口上限 + 审计)。**本版无新端点、无新字段表**,只有两处**取值域加性扩张**与一条**口径澄清**。
> 🔴 **可达性(先读这条,别误判)**:本版落地的是**判定侧(C1-1 `94da6bc`)与自排本体(C1-2 `ed50053`)** —— **她的入口工具 `request_think` 尚未接线**(属**下一片**,在办)⇒ **在生产里 `trigger="self_wake"` 与那 4 条审计动词目前还不会真的出现**。本节**登记的是形状**(写侧代码已在树里),客户端可以先把字段读进去,但**不要**把它当「一定会有」的活跃数据(同 §7.1 的登记口径)。
> 依据 `design/12 §N C`(自排:结构化 self-initiated 触发 + 她可调用的「自排一次思考」工具 + 每窗口上限 + 审计)。**v0.81 当时无新端点、无新字段表**,只有两处**取值域加性扩张**与一条**口径澄清**。
> ✅ **可达性(v0.82 已改正,别按旧口径读)**:v0.81 落地的是**判定侧(C1-1 `94da6bc`)与自排本体(C1-2 `ed50053`)**,当时她的入口工具 `request_think` **尚未接线** ⇒ 那一版写的是「生产里还不会真的出现」。🔴 **这句已过期**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`;启动日志自证 `self wake tool registered tool=request_think`)⇒ **`trigger="self_wake"` 与那 4 条审计动词在生产里会真的出现**。客户端可以把它当活跃数据读(仍**不保证每轮都有** —— 自排要过闸,见下)。
**(d-1) `GET /api/v1/admin/backthink` 的 `trigger` 出现新取值 `"self_wake"`**
@@ -845,7 +862,65 @@
| `backthink.self_wake.denied` | `backthink` | `非主人环境,拒绝自排(actor=%s)` |
- 只有 `queued` 会**真的**新增一轮思考;`coalesced` = 已有一轮在排队 ⇒ 合并进去(**不额外占额度**);`capped` = 触到每窗口上限(**默认 4 次/小时**);`denied` = 非主人环境(安全闸拦下,**不做事**)。
- ⚠️ **上限值不在契约里冻结**:它的真实来源是 **`$DATA_DIR/config/backthink.json`**(`selfWakePerWindow` / `selfWakeWindowSec`,**启动期读一次、改文件需重启**;**不新增 env 键** —— `.env` 已冻结,见 §5 与 `AGENTS §10`),且**目前没有 admin 端点可读可写**(写路径属后续切片 C2,未做)⇒ 客户端/面板**不要假定**能查到或改到这个值;要显示就用审计行 `result` 的原文。
- ⚠️ **上限值不在契约里冻结**:它的真实来源是 **`$DATA_DIR/config/backthink.json`**(`selfWakePerWindow` / `selfWakeWindowSec`,**启动期读一次、改文件需重启**;**不新增 env 键** —— `.env` 已冻结,见 §5 与 `AGENTS §10`)。✅ **v0.82 起有只读出口了**:新增 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e))给出「本窗口已用 N / 上限 M / 窗口秒数」的**真读数**,不必再从审计行 `result` 的原文里抠。🔴 **仍没有写口**:改上限/窗口依旧只能改配置文件 + 重启(面板写路径**未做**)⇒ 客户端/面板**不得假定**能改到这个值。
#### (e) v0.82 新增:自排的成本可见面(读侧)
> 依据 `design/12 §N C.5`(每窗口上限 + 容量 1 待跑槽)与 `§N C.6`(服务端筛选)。**两条都只读**。
**(e-1) `GET /api/v1/admin/backthink?trigger=<值>` —— 服务端筛选(v0.82)**
| 项 | 值 |
|---|---|
| 查询参数 | `trigger`(**可选**)。**缺席** ⇒ 不过滤(既有行为,老客户端零影响);**在场但为空** ⇒ **`400 invalid_trigger`**;**不在白名单** ⇒ **`400 invalid_trigger`**(错误文案里**列出可选取值**,客户端可直接显示) |
| 白名单(**闭集**) | `heartbeat` / `startup` / `self_wake` + **本服务订阅的事件主题**。真源 = `internal/services/backthink/trigger_domain.go` 的 `TriggerFilterValues()`(内建标签 + `EventSubjects()`)—— **网关不另抄一份清单**(否则新增订阅主题时会出现「数据里有、筛选却 400」的静默漂移) |
| 筛选方式 | **下推 SQL**(`ListByTrigger`),因此 `limit` 数的是**匹配**条数,而不是「取一页再筛掉不匹配的」(后者会给面板一个**静默的假空值**:「0 条」而库里明明有) |
🔴 **为什么未知值必须报错、不能静默忽略**:静默忽略会让面板在「显示的是全部」的同时声称「正在筛选自我唤醒」—— 那是一个**假的筛选结果**。**空值同理**(客户端把想筛选的值弄丢了却以为在筛)。
⚠️ **与 (d) 的「取值域不封闭」不矛盾(这是本版最容易被误读的一处)**:
- **显示口径**((d),仍然成立):`Record.Trigger` 的取值**对客户端是开放的** —— 事件主题那一支本来就是开放的;客户端**按字面值显示、不做闭集校验、不隐藏不认识的取值**;
- **筛选白名单**((e-1)):`?trigger=` 是**客户端传进来的参数**,打错了必须报错,而不是静默返回一个空列表(静默的空列表会被读成「这段时间没有这一类的思考」)。
- 两者面向的是**不同方向的数据流**(一个是我们给出去、一个是客户端传进来),因此**不冲突**;判据的真源仍是**同一处**(`trigger_domain.go`)。
**(e-2) `GET /api/v1/admin/backthink/self-wake` —— 自排闸只读快照(v0.82 新增)**
**为什么有它**:`C.5` 的「本窗口已用 N / 上限 M」此前**只**出现在**被拒那一轮**的审计文案里(`backthink.self_wake.capped` 的 `result`)—— 也就是**「已经被拒过一次」才第一次看得见**。面板要能**提前**告诉主人「还能自排几次」,就得有一个只读快照出口。
**鉴权**:`writeAuth`(**owner / admin**;viewer → `403`)。理由:它含**她自排的成本口径与运行状态**,不是给 viewer 的监控投影(与列表端点 B8 同门同档)。**只读、不审计、不写库**。
**响应**:`{data:{available,running,reason?,configFile?,gate?}}`
| 键 | 类型 | 说明 |
|---|---|---|
| `available` | bool,**恒在** | 本进程**能不能**给出快照。`false` 的含义是「看不到」,**不是**「0 次」 |
| `running` | bool,**恒在** | 自排此刻**是否真的能排**(未 Start / 已 Stop ⇒ `false`:此刻 `WakeNow` 会返回 `ErrSelfWakeUnavailable`) |
| `reason` | string,**可选**(缺席 = 正常态) | 人类可读的原因。三种非正常态各一句:未接线 / 实现不提供读口 / 闸不在 / 未在运行 |
| `configFile` | string,**可选** | 要改哪个文件 —— **相对落点** `config/backthink.json`(相对 `$DATA_DIR`)。绝对路径随部署而变,接口只给相对落点 |
| `gate` | object,**可选**(`available:false` 时**整键缺席**) | 见下表 |
`gate` 子对象:
| 键 | 类型 | 说明 |
|---|---|---|
| `pending` | bool | 容量 **1** 的待跑槽是否被占用(有一轮自排排着队还没跑) |
| `used` | int | 本窗口已用额度,与工具收据 `SelfWakeReceipt.Used` **同一口径**(同一次取数:过期的记录先被丢弃,再计数) |
| `limit` / `windowSec` | int | **进程生效**的上限与窗口(已 normalize:`≤0` 的配置不会走到这里)。窗口单位是**秒** |
| `defaultLimit` / `defaultWindowSec` | int | 代码默认值 |
| `limitIsDefault` / `windowIsDefault` | bool | 生效值**恰好等于**代码默认值 |
🔴 **`*IsDefault` 不是「配置里没写这个键」(诚实边界,与 B12 config-facts 同一条口径)**:网关**不读** `backthink.json`,因此**无从区分**「配了 4」与「没配(默认也是 4)」⇒ 客户端**只能**把它读作「生效值是否等于默认值」,**不得**显示成「未配置 / 已自定义」。要表达「未配置」需要另一次取数(当前**没有**)。
**三态(面板按此显示,别把「看不到」画成「0 次」)**:
| `available` | `running` | `gate` | `reason` | 面板该说什么 |
|:--:|:--:|:--:|---|---|
| `false` | `false` | **缺席** | 有 | 「读不到自排状态」+ 原因原文。**不要**显示 0 / 0 |
| `true` | `false` | 有(配置面数字) | 有 | 「后台思考未在运行:此刻她排不出新的一轮」+ 仍显示生效上限/窗口 |
| `true` | `true` | 有 | **缺席** | 正常:显示「本窗口已用 `used` / 上限 `limit`」+ 待跑槽占用 |
- **只在 `running:true` 时才可以说「她还能排 N 次」**:`used` 是**已用**额度(`limit - used` 是剩余,但闸的判定始终由滑窗与待跑槽做,**读本快照不参与判定、也不改变闸的状态** ⇒ 读它永远不会消耗额度)。
- **`available:false` 的 HTTP 状态是 `200`**(不是 5xx):这是「本进程给不出这张快照」的**如实答复**,与 B8「未接线时返回空列表」同族 —— 面板据 `reason` 显示即可,**不要**当成请求失败重试。
### 4.11 链路图快照 + 多轮历史回放(`GET /api/v1/admin/flow/graph`;v0.71 新增,v0.73 增量,v0.77 增量)
@@ -1085,11 +1160,20 @@
"order": 20,
"route": "plugin/web-search/providers", // **相对**管理面板前缀
"sections": [ /* design/09 §5.2 的 section 声明**原样**下发(字段名逐字同形) */ ]
},
{
"pluginId": "browser", // **v0.82 新增**(切片 Y4,见 §4.14(d))
"id": "settings",
"title": "浏览器与截图",
"icon": "browser", // 前端把它映射到侧边栏的「图片」图标(图标表闭集里没有专门的浏览器图标)
"order": 30,
"route": "plugin/browser/settings",
"sections": [ /* 4 个区块:form 配置 + 3 张只读 table(状态 / 指纹 / 截图) */ ]
}
]
}
],
"pluginIds": ["web-search"] // **声明过面板**的插件 id(已排序);≠ 已安装列表
"pluginIds": ["browser", "web-search"] // **声明过面板**的插件 id(**已排序**);≠ 已安装列表。⚠️ v0.82 的 `browser` 项在本版**登记时仍待提交**,提交前真机上是 `["web-search"]`
}
```
@@ -1099,7 +1183,11 @@
**错误**:`501 {error:{code:"panels_unavailable"}}`(未接线);`403 unknown_role`(角色无法识别,理论不可达,fail-closed)。
> ⚠️ **当前运行期快照不是空的**:内置插件 `web-search`(`plugins/tools/io/search` 的 `AdminPanels()`)在**组合根装配期**声明给中枢 ⇒ 正常部署下 `pluginIds == ["web-search"]`。热装插件(MCP 等)**目前恒为「未声明管理页面」**(声明来源 `manifest.json` 的读取未实现,见 §7.2)。
> ⚠️ **当前运行期快照不是空的**:内置插件在**组合根装配期**声明给中枢 ⇒ 正常部署下有两个条目:
> - `web-search`(`plugins/tools/io/search` 的 `AdminPanels()`,随 v0.78 起);
> - `browser` / `settings`(`plugins/tools/browser` 的 `AdminPanels()`,**v0.82 新增**,切片 Y4,见 §4.14(d))—— 它写在 `cmd/cyrene/wire_frontend.go` 的 `wirePanelHub` 里(`wireBrowserPanel(hub, a, a.log)`,**登记时该挂点仍在工作树、未提交**);删掉这一行**没有任何编译期或运行期症状**,只是目录里少一页、六个动作全 `404 action_not_declared`,因此有一条**源码级**用例盯着它。🔴 **在跑的那份产物里没有它**(真机已核:启动日志全篇 **0 条 `browser panel declared`**,而该函数成功声明时必打这一行;同一次启动里 `self wake tool registered tool=request_think` 是有的)⇒ 那一刻真机上 `pluginIds == ["web-search"]`。
>
> ⇒ 接线后 `pluginIds == ["browser","web-search"]`(**已排序**)。热装插件(MCP 等)**目前恒为「未声明管理页面」**(声明来源 `manifest.json` 的读取未实现,见 §7.2)。
#### (b) `POST /api/v1/admin/plugins/{id}/actions/{action}` —— 插件动作分发(**v0.78**)
@@ -1161,6 +1249,52 @@
> ⚠️ **v0.79 行为变化(登记)**:`optionsFrom` 声明的候选来源现在会进**动作索引** ⇒ `plugin.providers` 从「`404 action_not_declared`」变为**可调用的读动作**。前端此前对该动作 404 的降级分支(文本框 + 「候选项未能加载」)因此**不再触发**。⚠️ 另有一条**前端缺口**(不属本契约面):行内动作的成功分支只显示「已完成,正在刷新」,丢弃 `result` ⇒ 探针成功时的 `count` / `latencyMs` 在界面上仍不可见。
#### (d) `browser` 插件的动作面(**v0.82 新增**,切片 Y4;design/28 §8 B4)
插件 id `browser`,面板 id `settings`(「浏览器与截图」,`route = plugin/browser/settings`)。**不开任何新路由** —— 六个动作全部走上面 (b) 的通用分发端点 `POST /api/v1/admin/plugins/browser/actions/{action}`;可变源落点 `$DATA_DIR/config/plugins/browser.json`(**0600**、temp+rename;**文件名 = 插件 id**)—— ⚠️ **不进客户端契约**(客户端一律走动作端点,不直连文件)。
**页面构成(4 个区块)**:① `form` 配置表单(保存即热重载);② / ③ / ④ 三张只读 `table`(运行状态 / 指纹自检 / 最近截图)。
> **为什么三块活数据都用 `table` 而不是 `status`**:`status` 区块按 §5.2.1 的接入点表**不消费 `source`**(没有来源就没有活数据),硬要用只能给一份声明期的静态文本 —— 那才是真正的「看起来有、其实没有」。`table` 是声明面里唯一**既有来源又能带按钮**的形状。
**六个动作(3 读 3 写;全部已登记生产处理器)**:
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|---|---|---|---|---|
| `plugin.getBrowserState` | 运行状态只读事实(**不启动浏览器**) | read(table `source`) | `{}` | `{"rows":[{"source","available","headless","viewport","profileDir","concurrency","idleTimeoutSeconds","shotMaxBytes"}]}`。**恒为 1 行**(事实只有一份;并发上限恒为 1)⇒ 多行会诱导读者以为「有多个浏览器在跑」 |
| `plugin.getFingerprint` | **上一次**指纹自检的读数(**不启动浏览器**) | read(table `source`) | `{}` | `{"rows":[{"webdriver","screen","timezone","language","userAgent","checkedAt"}]}`。**从未自检过 ⇒ 一行如实写「未自检」**(不编造数字、不回空数组) |
| `plugin.listShots` | 最近截图**统计**(**最新在前**,最多 20 条) | read(table `source`) | `{}` | `{"rows":[{"at","bytes","size","format","degraded"}]}`。🔴 **不含路径、不含内容、不含 URL**(面板是只读投影,viewer 也看得到表格行 ⇒ 把主人的浏览内容投到那里是越界)。没截过图时**如实为空表** `{"rows":[]}` |
| `plugin.saveBrowserConfig` | 保存浏览器配置(落盘 + **热重载**) | write(form `submit`) | `{"values":{…}}`(**只发改动过的字段**;缺位的键**保持原值**,**绝不**把「没提到」当「清空」) | `{"saved":true,"applied":<bool>,"registered":<bool>,"reason":"<原因码/文案>"}`。`applied=true` = 这次保存**真的触发了热重载**(按落盘后的配置重建工具 + 原子替换注册表条目 ⇒ **下一轮生效、不需重启**);`applied=false` 且保存前**本来没有**可用工具 ⇒ 文件留下、如实报 `false` + `reason`(那种情形没有能力可丢,报错反而会让主人**没法先配好**) |
| `plugin.shootPage` | 拍一张**当前页面** | write(table `rowActions`) | `{"row":{…}}`(载荷不被解释,只为触发) | `{"shot":{…}}`,`shot` = `browser` 工具 `screenshot` 的返回值原样(见下面「模型面」一节),含 `path` / `bytes` / `sha256` / `format` / `width` / `height` / `dimensionsKnown` / `degraded` / `turnAttachment` / `hint` |
| `plugin.checkFingerprint` | 重新做一次指纹自检(**会启动浏览器**) | write(table `rowActions`) | `{"row":{…}}` | `{"rows":[…]}`(与 `plugin.getFingerprint` 同形,刷新后的读数) |
**字段名(`values` 的键,逐字与 `$DATA_DIR/config/plugins/browser.json` 的 JSON tag 一致)**:`headless`(bool)、`executable`(string,留空 = 自动探测本机 Chrome/Chromium)、`cdpEndpoint`(string,显式给出时取代自动探测)、`profileDir`(string)、`viewportWidth` / `viewportHeight`(number,声明面已给 min/max)、`idleTimeoutSeconds`(number)、`concurrency`(number,**声明面 min = max = 1**:同一 profile 起两个实例会互锁)。🔴 **未知字段一律拒(`400 invalid_payload`),绝不静默忽略** —— 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
**错误码(本插件在 (b) 通用表之上追加的,宿主只透传「码 + 文案 + HTTP 状态」)**:
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|---|---|---|---|
| `400` | `invalid_payload` | 载荷不是对象 / 缺 `values` / `values` 不是对象 / **未知字段** / 字段类型错 | 显示文案原文;**不要**当成「保存成功」 |
| `409` | `browser_unavailable` | 本机**没有**可用浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)⇒ `plugin.shootPage` / `plugin.checkFingerprint` 做不了 | 提示「先去配置里填可执行文件或 CDP 端点并保存」;面板可以配 ⇒ 不是死路 |
| `409` | `browser_session_not_open` | `plugin.shootPage` 时浏览器**没有打开的页面** | 提示「先让她打开一个页面(或在有头窗口里人工接管)」—— **面板刻意不替模型开页**(否则它就成了「任意 URL 的截图器」) |
| `409` | `config_rejected` | 这次保存**没能生效**(新配置会让工具不可用),**已按字节回滚**到保存前那一份 | 显示原因原文;**盘上没有变**,不必再手动改回 |
| `500` | `config_unreadable` | 面板配置存在但**读不出来**(损坏 / 权限)⇒ 本次**没有**改动它 | 显示「配置读不出来」,指向服务端日志;**不要**重试保存 |
| `500` | `config_write_failed` | 配置写不进去(磁盘 / 权限);⚠️ **回滚也失败**时也用这个码,文案明说「盘上的文件可能已经变了」 | **不假装改动没发生**;提示可能需要人工检查盘上文件 |
| `502` | `browser_action_failed` | 浏览器动作本身执行失败(截图 / 自检) | 详细原因只进后端日志;显示「执行失败」+ 文案 |
> **`applied:false` + `reason` 是正常结论,不是错误**:保存前**本来就没有**可用工具时(例如第一次配一个还没起来的容器端点),文件留下、`200` + `applied:false`。客户端**不要**把 `applied:false` 渲染成失败 —— 要说的是「已保存,但这次没有东西可换/没生效」+ `reason`。
**模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(**闭集**,v0.82):
| 取值 | 含义 |
|---|---|
| `queued` | 截图已入**本会话的待送附件箱**,将在本回合的下一次模型调用里作为附件送出(主模型收不了图时以视觉/OCR 的文字形态) |
| `no-session` | 本次调用拿不到会话 id(子代理 / 未接编排器的调用),**未入箱** ⇒ 由模型自己按 `path` 去读 |
| `outbox-full` | 该会话的箱子已满(**每会话最多 4 张**、TTL 10 分钟),**丢掉了最旧的那张**(如实报,不静默) |
| `disabled` | 本工具没有附件箱(`New(nil)` 这类夹具),未入箱 |
配套的 `hint` 字段是一句**如实**的下一步提示(`queued` 时说「已经作为图片附件送出」;其余档说「图没进上下文,按路径读」)。**「入箱」到「进上下文」靠 `loop.Options.TransformContext` 上挂的钩子**(`cmd/cyrene/wire_browser_context.go`)—— 它**取走即消费**(同一张图不会被送两次),也因此**不会**进 checkpoint、不会进后台记忆提取的输入面。⚠️ **该挂点在 v0.82 登记时仍在工作树、未提交**;缺了它,`turnAttachment` 仍会照报 `queued` 而图**一张都不会进上下文**(**零症状**的坏法,因此有一条源码级用例盯着 `wire_runtime.go` 那一行)。
---
## 5. env 与模型配置(与契约相关)
@@ -1236,7 +1370,26 @@
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.81(当前契约版本)
### v0.82(当前契约版本)
**三面增量 —— ① `tool_execution.end` 带可选 `resultSummary`(工具执行结果实时可见,切片 Y1 `215eedf`)|② 自排的成本可见面:`?trigger=` 服务端筛选 + 新端点 `GET /admin/backthink/self-wake`(切片 Y3 `4213b26`)|③ `browser` 插件的管理面板与六个动作(切片 Y4 `e0a6bca`)**(依据:安卓端按 ① 把工具卡 UI 定死;`design/12 §N C.5/C.6` 的自排成本口径与筛选;`design/28 §8 B4` 的面板入口。)
- **① `tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)**:`{tool, phase, id?, delta?, resultSummary?}`。数据来源 = **loop 侧**在工具返回值落定的那一刻生成(`internal/runtime/loop/tool_frame_summary.go`),走的是**与出图同一条**脱敏路径(`trace.RedactText`:凭据形状清洗 + 截断),**不从上游原文反解、不新开脱敏通道**。修的是「实时只看到工具名与状态、要事后翻回看面才知道结果」。**§3.4.2**
- 🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态**。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空。**绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。`tool_execution.start` / `.update` **不带**该键。
- 🔴 **失败也带(本版口径)**:调用**失败 / 取消 / 超时**的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与**回看面**(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。⚠️ **本版之前**(切片 Y1 的首版实现)失败时是**整键缺席**、回看面却有 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」;本版把这处张力收掉了。
- **预算**:与回看面 `toolCalls[].resultSummary` **共用同一个常量与同一个截断函数**(`maxToolCallSummaryRunes = 500`,超长按 rune 截断、末位 `…`)。网关侧**只截断、不做二次脱敏**(与回看面「只搬运、不加工」同一口径)。
- **② 自排的成本可见面(切片 Y3)**:
- `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(design/12 §N C.6)。取值域 = **闭集**(`heartbeat` / `startup` / `self_wake` + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`);**值未知或为空 ⇒ `400 invalid_trigger`**(**绝不静默忽略** —— 那会让面板在显示全部的同时声称「正在筛选」);合法值**下推 SQL** ⇒ `limit` 数的是**匹配**条数。🔴 **「筛选白名单是闭集」与「`trigger` 的显示取值域不封闭」不矛盾**(前者约束客户端传进来的参数、后者约束我们发出去的显示值),见 §4.10(e-1)。
- 新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(design/12 §N C.5 的读侧)。响应 `{data:{available,running,reason?,configFile?,gate?}}`,`gate` = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`。**三态必须可区分**(面板不得把「看不到」画成「0 次」);`available:false` 时 `gate` **整键缺席**且 `reason` 说明原因、**HTTP 仍是 `200`**。🔴 **`limitIsDefault` / `windowIsDefault` 只说「生效值恰好等于代码默认值」**,**不是**「配置文件里没写这个键」(网关不读 `backthink.json`,与 B12 的 `isDefault` 同一诚实口径)。🔴 **它只读、不审计、不写库**,也**不含任何可改配置的入口**(改上限/窗口仍要改文件 + 重启)。**§2.2 B19 / §4.10(e-2)**
- 🔴 **同版改正一处已成假话的旧口径**:v0.81 写的「`request_think` **尚未接线** ⇒ `self_wake` 与 4 条审计动词在生产里不会出现」**已过期** —— 入口工具**已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`;启动日志自证 `self wake tool registered tool=request_think`)⇒ **它们在生产里会真的出现**。§4.10(d) 与 §7.1 已同步改。
- **③ `browser` 插件的管理面板与六个动作(切片 Y4;design/28 §8 B4)**:`GET /api/v1/admin/panels` 的目录新增 **`browser` / `settings`**(「浏览器与截图」,`route = plugin/browser/settings`,4 个区块 = 1 个 `form` 配置 + 3 张只读 `table`),该插件声明 **6 个动作**(3 读 `plugin.getBrowserState` / `plugin.listShots` / `plugin.getFingerprint`;3 写 `plugin.saveBrowserConfig` / `plugin.shootPage` / `plugin.checkFingerprint`),**全部已登记生产处理器**,**不开任何新路由**(走既有 A14 通用动作分发)⇒ **§2.1 的 14 与总数不因本项变化**。保存 = **落盘 + 热重载**(重建工具 + 原子替换注册表条目 ⇒ 下一轮生效);保存前**本来有**可用工具而新配置让它不可用 ⇒ **回滚文件** + **`409 config_rejected`**;保存前**本来没有**可用工具 ⇒ 文件留下 + `200` + `applied:false` + `reason`(**不报错**,否则主人没法先配好)。🔴 **读面绝不启动浏览器**(面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界);真正会起浏览器的只有 `plugin.shootPage` / `plugin.checkFingerprint` 两个写动作(`plugin.saveBrowserConfig` 的「热重载」只在**重建工具**时按配置探测可执行文件)。**§4.14(d)**
- **③ 附:模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`,配一句如实的 `hint`)—— 让**模型与面板**都能看出「这张图到底进没进上下文」,而不是只在日志里。⚠️ **`queued` 与「进上下文」之间还有一环**:`loop.Options.TransformContext` 上挂的装配钩子(`cmd/cyrene/wire_browser_context.go`)负责**取走即消费**地把它并进本轮视图。
- **行为变更**:**无**(既有键逐字未改;新增的都是**可选键 / 新端点 / 新声明**)。
- **破坏性**:**无**(`resultSummary` 是新可选键,老客户端不读即行为逐字不变;`?trigger=` 缺席即不过滤;`GET /admin/panels` 多一条目录项 —— 按 `pluginIds` 差集渲染「未声明管理页面」的客户端零影响)。
- **计数**:`writeAuth` 段 **18 → 19**(新增 B19 `GET /admin/backthink/self-wake`)、管理路由总数 **72 → 73**(`14 + 19 + 40`);§2.2 行数 **B1–B19**。**本版无其它新路由**(③ 走既有 A14)。
- ⚠️ **诚实边界**:① **①③ 的形状取自「工作树实现」** —— 登记时 ① 的「失败也带」(`internal/runtime/loop/tool_frame_summary.go`)与 ③ 的**两个组合根挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)**都还在工作树、尚未提交** ⇒ **提交前生产里没有这些行为**;提交后请按 `git show` 逐字复核(见 §7.4-30 / §7.4-31);② ① 的**真机**(`:10942` 发布产物)端到端**未跑**;③ ② 的**真机面板端到端未跑**(面板写路径**未做**,只有读侧);④ ③ 的**真机 e2e 未跑**(既有 `wire_browser_e2e_test.go` 默认 `Skip`,需要真 Chrome + 可执行文件路径)。
### v0.81(上一版)
**一批增量 + 两处真缺陷修复 —— ① 她自排轮(`self_wake`)的用户可见面(形状登记;生产暂不可达)|② 时间轴新增 `retentionBoundaryReached` 并与 `truncated` 分意(切片 X1)|③ `round` 跨重启不再重复(切片 X1 的行为修复)**(依据:`design/12 §N C` 的自排切片 C1-1 `94da6bc` / C1-2 `ed50053`;`design/19 §7` 时间轴的保留期诚实口径与 §6.1 的「`round` 每会话单调」;`AGENTS §9.5` 的对照要求。)
@@ -1246,7 +1399,7 @@
- **行为变更**:**有(但都是「变真」而非「变形」)**:① 两处取值域**加性**扩张(只显示 `trigger` 原文的客户端零影响);② ③ 是**行为修复**(重启后轮次号不再重复 ⇒ 分组不再并块);② 是**字段加性**扩张 + 对既有 `truncated` 的**读数收紧**(此前把它当清扫信号读的客户端会发现自己原来读到的是假话)。
- **破坏性**:**无**(无键被删、无键被改义;`truncated` 的**定义**未变,变的是「不得拿它当清扫信号」这句口径)。
- **计数**:**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 B1–B18 共 **18** 条;②③ 都是往既有 B18 的响应/行为上加东西)。
- ⚠️ **诚实边界**:① 自排的**端到端生产验证未做**(入口工具未接线,见 ①);② **②③ 的形状与判据取自切片 X1 的「工作树实现」**(本版登记时该切片**尚未提交**,`f1fbc24` 之后仍在工作树)⇒ **commit 后请按 `git show` 逐字复核键名与判据**;③ ② 的三条盲区**没有测试能覆盖到「整段被清扫」那一支**(属设计登记的诚实边界);④ 时间轴**真机面板端到端**仍未跑(同 v0.80 ①);⑤ 同波其余切片(技能渐进披露 L2 `6475eb8`、示例技能 V3 `4a04ad4`、浏览器 B1/B2 V5 `f1fbc24`)**对客户端零形状影响**,故未逐条登记(浏览器是**模型面工具**,不进客户端契约面)。
- ⚠️ **诚实边界**:① 自排的**端到端生产验证未做**(入口工具未接线,见 ①);② **②③ 的形状与判据取自切片 X1 的「工作树实现」**(本版登记时该切片**尚未提交**,`f1fbc24` 之后仍在工作树)⇒ **commit 后请按 `git show` 逐字复核键名与判据**;③ ② 的三条盲区**没有测试能覆盖到「整段被清扫」那一支**(属设计登记的诚实边界);④ 时间轴**真机面板端到端**仍未跑(同 v0.80 ①);⑤ 同波其余切片(技能渐进披露 L2 `6475eb8`、示例技能 V3 `4a04ad4`、浏览器 B1/B2 V5 `f1fbc24`)**对客户端零形状影响**,故未逐条登记。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面,见 §4.14(d)。
### v0.80(上一版)
@@ -1521,10 +1674,16 @@
| **`interrupt.turnId`** | **已上线**(`c0dc435`) | §4.8;`ws_hub_turn.go`、`session_interrupt.go` |
| **消息 DTO `turnId`**(含 `chat_messages.turn_id` 迁移 `0018`) | **已上线**(`d64b26d` + `d804c4e`) | §4.4;`history.go`、`session_turn_id.go` |
| **写类工具文件级互斥**(读共享 / 读写与写写互斥,被占住回 `ErrFileBusy` 文案给模型) | **已上线**(`354b08a`、`ef3dcb6`)—— **纯服务端行为,无客户端可见契约面** | `internal/runtime/tool/{fileclaims.go,legfile.go,tool.go}`、`loop/tools.go` |
| **保留期清扫的「可分辨键」`retentionBoundaryReached`**(v0.81 新增,切片 X1) | 🟡 **已实现;登记时仍在工作树、待提交** | 作用:把「更早的轮次**被保留期清扫**」与「分页还有更多 / 本来就没有更早的轮次」**分开**(`truncated` 只答「本次是不是全部」)。形状 / 判据 / 三个盲区见 **§4.11.1(a)(b)-3**;`round` 跨重启单调的同期修复见 **§4.11.1(e)**。⚠️ **它是在本版登记之后才 commit 的**(本片无提交权限)⇒ 客户端可用,但**提交后请按 `git show` 逐字复核键名与判据** |
| **她自排的入口工具 `request_think`**(→ 使 `GET /admin/backthink` 的 `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**真的可能出现**;v0.81 登记) | 🔴 **未落地(下一片在办;判定侧与自排本体已落地)** | 已落地:判定侧 C1-1 `94da6bc`、自排本体 C1-2 `ed50053`(含窗口上限 + 容量 1 待跑槽 + 4 条审计)。**缺的是她的入口工具**(`request_think`,`internal/services/backthink/thinktool.go` 尚未提交)⇒ **生产里目前不会产生自排轮**。形状见 §4.10(d);客户端**不要**依赖该取值一定出现 |
| **保留期清扫的「可分辨键」`retentionBoundaryReached`**(v0.81 新增,切片 X1) | ✅ **已上线(`2bf3b53`)** | 作用:把「更早的轮次**被保留期清扫**」与「分页还有更多 / 本来就没有更早的轮次」**分开**(`truncated` 只答「本次是不是全部」)。形状 / 判据 / 三个盲区见 **§4.11.1(a)(b)-3**;`round` 跨重启单调的同期修复见 **§4.11.1(e)**。⚠️ v0.81 登记时它**仍在工作树**(该版已如实标注);**现已提交** ⇒ 按 `git show 2bf3b53` 复核键名与判据 |
| **她自排的入口工具 `request_think`**(→ 使 `GET /admin/backthink` 的 `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**真的可能出现**) | ✅ **已接线(切片 X2 `2d211c2`)** —— v0.81 登记时的「🔴 未落地」**已作废** | 判定侧 C1-1 `94da6bc` + 自排本体 C1-2 `ed50053`(窗口上限 + 容量 1 待跑槽 + 4 条审计)+ **入口工具 `request_think` 已注册**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,`internal/services/backthink/thinktool.go`)⇒ **生产里会真的产生自排轮**;启动日志自证 `self wake tool registered tool=request_think`。形状见 §4.10(d);⚠️ 自排轮仍要过既有 `decide` 级联 + `PushGate`,**不保证每轮都有**。**读它用了多少额度的出口**见 **§4.10(e-2) / §2.2 B19** |
| **`tool_execution.end` 的 `resultSummary`**(v0.82 新增,切片 Y1 `215eedf`;**「失败也带」在工作树**) | 🟡 **已实现;「失败也带」登记时仍在工作树、待提交** | 形状与缺席语义见 **§3.4.2**。⚠️ 提交前跑着的后端里**没有**该键;提交后请按 `git show` 复核「失败路径给 error 摘要」这条口径(`internal/runtime/loop/tool_frame_summary.go` 的 `toolEndFrameSummary`) |
| **`browser` 面板与截图闭环的组合根挂点**(v0.82 新增,切片 Y4 `e0a6bca`) | 🔴 **登记时未接线**(代码已落库,两个生产挂点在工作树) | 声明与六个动作见 **§4.14(d)**;`wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)` 与 `wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),` 是**登记时**才进工作树的(删掉都**零症状**,各有源码级用例盯着)。🔴 **在跑的那份产物里没有它们**(真机已核:启动日志全篇 0 条 `browser panel declared`)⇒ 那一刻 `pluginIds == ["web-search"]`、`browser` 动作全 `404 action_not_declared`、`turnAttachment` 照报 `queued` 但图不进上下文 |
> ⚠️ **工作树另有未提交改动**:2026-10-06 晚的工作树上有**并行切片**的在途改动 —— 其中 **X1 的时间轴两项**(`retentionBoundaryReached` + `round` 跨重启播种)**已按「登记形状 + 明标未提交」进上表**;其余(她自排的入口工具 `request_think`、浏览器 SSRF 收口、`capacity` / 面板 `Backthink.tsx` 等)**尚未冻结、本表不登记** ⇒ 客户端**不要**按当前工作树形态编程。⚠️ 本注原举的 `<p>` 段落规整(`internal/runtime/turnreply/`)与前端 `turn` 分桶**均已提交**(不再是在途项),故删除该例。
> ⚠️ **工作树另有未提交改动(v0.82 登记时的实测状态)**:**与本版两个面相关**的在途文件共 **5 个**(此外工作树还有同波其它切片的在途改动,与本契约无关、不影响本文档):
> - **① `resultSummary` 的「失败也带」**:`internal/runtime/loop/tool_frame_summary.go` + `..._test.go`(已按「登记形状 + 明标未提交」进上表);
> - **③ `browser` 的两个生产挂点**:`cmd/cyrene/wire_runtime.go`、`cmd/cyrene/wire_frontend.go`、`cmd/cyrene/wire_browser_context_test.go`(同样已进上表)。
>
> 其余在途项(若有)**尚未冻结、本表不登记** ⇒ 客户端**不要**按当前工作树形态编程。⚠️ v0.81 时代本注举的项(X1 的时间轴两项、`request_think`、浏览器 SSRF 收口、`capacity` / 面板 `Backthink.tsx`)**均已提交**(`2bf3b53` / `2d211c2` / `a820d99`),不再是在途项。
> 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是**归档时的代码状态**,**已过期**。
### 7.2 确认为「未实现」(别当已有)
@@ -1546,7 +1705,9 @@
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind) |
| **✅ 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **已实现(v0.79,切片 L45;v0.77 登记时无 HTTP 入口)**:两条都走 `writeAuth`(owner\|admin),形状见 §2.2 B16/B17。⚠️ **残留**:① 留痕列表**只有 `limit`、没有分页游标**(无 `before`/`offset`);② `memory_ingest_reviews` **无 TTL / GC**;③ 回滚**只回滚「被吸收行」这一侧**(不恢复存活行被抬高的 `confidence`);④ 门与 `design/23 §11` 表格的 owner-only **有差异**(待裁决)。同样是**新审计动词** `memory.review.revert`(`design/23 §16.4` 表里还没有它,需设计侧补记) |
| **✅ v0.78 插件动作的「执行器」**(`POST /admin/plugins/{id}/actions/{action}` 的动作本体) | **已接线(v0.79,切片 L1/PLG-2 执行面接线)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions`;单一全局 `SetExecutor` 已删除),组合根在 `wirePanelHub` 里登记内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` ⇒ **这些动作真执行**(真读写可变源、探针真发一次 HTTP)。**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(不再是兜底档)。✅ **v0.80(切片 W3)**:面板保存后**热重载**(重建工具 + 原子替换注册表条目,**下一轮生效、不需重启**;会让后端下次起不来的配置 ⇒ **按字节回滚** + `409 config_unstartable`);面板**已可配主 provider / 降级链**(`primary` / `fallback[]`)。⚠️ **残留**:**探针只用 `httptest` 假上游**(未对真实上游连通);**启用态不跨重启持久化**;**真机面板端到端未跑**。见 §4.14(b)(c) / §7.4-26 / §7.4-27 |
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。当前唯一有声明的是内置 `web-search`(组合根装配期 `Declare`) |
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。有声明的是**内置**插件(组合根装配期 `Declare`):`web-search`(v0.78 起)、**`browser` / `settings`(v0.82 起,切片 Y4)** |
| **🔴 v0.82 `browser` 面板与 B3 截图闭环的「生产挂点」** | **登记时未接线**(切片 Y4 `e0a6bca`):声明与六个动作的代码已落库(§4.14(d)),但**组合根两行**(`wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`wire_runtime.go` 的 `TransformContext`)**在工作树、尚未提交** ⇒ 提交前:真机上 `pluginIds == ["web-search"]`、`POST /admin/plugins/browser/actions/*` **全部 `404 action_not_declared`**、`browser` 工具的 `screenshot.turnAttachment` 照报 `queued` 而图**不会**进上下文。**客户端不要**按未提交形态编程(同 §7.1 那一行) |
| **🔴 v0.82 `browser` 面板 / `resultSummary` 的「真机端到端」** | **未确证**:两者都只有测试进程内的证据(`httptest` / 真 hub / 真 loop),`:10942` 上的发布产物是旧构建 ⇒ 真机端到端一律未跑。见 **§7.4-30 / §7.4-31** |
| **`PUT/POST /api/v1/admin/plugins/{id}/enable\|disable`** | **未实现**(切片 PLG-2):`Manager.SetEnabled` 已实现并有测试,但**没有 HTTP 面** ⇒ 客户端**不得假定**能经 REST 启停插件(`panels_changed` 的 `enable`/`disable` 两种 `reason` 因此当前**不会**在运行期出现)。⚠️ 且启用态**不跨重启持久化**(`PluginConfig` 无 `enabled` 字段,`SetEnabled(false)` 只在进程内生效,重启按「已安装」恢复为启用) |
| **`panels_changed` 帧对 `viewer` 可见** | **未实现(刻意)**:该帧**未**登记进 `frameReachesAll` 白名单 ⇒ 按 fail-closed 默认档**只发 owner/admin**,**viewer 收不到**(须自行刷新/轮询重拉 A13)。放开需先改 `ws_frame_scope.go` + 在 `design/11 §7.1` 登记理由(**不在本版**) |
| **插件动作审计的持久化粒度** | **未含 pluginId / action**(切片 PLG-2):持久化审计记录沿用既有 `AuditStore.Append(ctx, who, what, result, ts)` 四字段 ⇒ 只落 `what="plugin.action"`;插件 id 与动作名只在**结构化日志行**里。要进持久化记录需改 `AuditStore` 签名(**不在本版**) |
@@ -1629,6 +1790,10 @@
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` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
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 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
31. **(v0.82 登记)`browser` 面板 / B3 截图闭环未做真机端到端**:证据止于 `cmd/cyrene` 的真 hub + 真能力注册表用例(`wire_browser_panel_test.go`:声明进目录、六个动作真能分发、读写面分类、保存真热重载 + 回滚;`wire_browser_context_test.go`:真 loop + 探针适配器 → 模型请求里确实出现 image 部件)。⚠️ **仍未确证**:① 真机面板上点开 `/admin/plugin/browser/settings` 能看到 4 个区块与真实行(需 owner 凭据 + 换产物);② 真机上保存一次浏览器配置后**下一轮**真的用新配置(热重载的真机确认);③ 真机**有头**窗口里「拍一张 → 图真的进下一轮上下文」的端到端(既有 `wire_browser_e2e_test.go` **默认 `Skip`**,它需要真 Chrome + 可执行文件路径 + 真模型);④ **登记时两个挂点尚未提交** ⇒ 上述三项在提交前**不可能**成立。**为什么验不了**:同上(禁换产物 / 禁重启后端),且本机浏览器来源与凭据未在测试环境确证。
---
*本文件由「以代码为真源」维护(当前 `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.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/**`、`internal/runtime/voice/**`、`internal/services/channelbridge/**`(`inbound_voice*.go`)、`pkg/plugins/sdk/panel.go`、`cmd/cyrene/**`(`wire_frontend.go` / `wire_io_tools.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.4(c) / §3.2 / §3.4.4 / §3.4.5 / §4.1 / §4.4 / §4.5 / §4.14 / §5 / §6 / §7.2 / §7.3 / §7.4);`v0.77`(链路图按路分桶 FV2 + 记忆整理护栏/异步 MT-A + 入库前审查 env 键 MT-B)同步了 `internal/runtime/trace/graph.go`、`internal/runtime/loop/**`(`flow.go` / `flow_branch.go` / `run.go` / `steer.go`)、`internal/runtime/orchestration/**`、`internal/runtime/flowgraph/**`、`internal/runtime/memory/**`(`tidy*.go` / `preinsert*.go`)、`internal/config/**`(`config.go` / `memory_review.go`)、`internal/services/gateway/**`(`admin_flow.go` / `contracts.go` / `admin_config_facts.go` / `admin_memory_tidy*.go` / `routes.go`)、`frontend/web/src/store/**`、`frontend/web/src/admin/modules/flow/**` 与本文件(§2.2 B12/B13/B15 / §3.4.5 / §4.1 / §4.10(a) / §4.11 / §4.12 / §4.12.1 / §5 / §6 / §7.2 / §7.4);`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 该阻塞**已由 v0.78(CODE-TC 的迁移 `0023` + `GraphsByTurnID`)解除**,`toolCalls[]` 已实现(覆盖率**已由 v0.79 的 CODE-TC-2 补齐至全部编排回合**);v0.76 的 ③ 试算加性异步**已由 v0.77(MT-A)落地校正**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tLine truncated
*本文件由「以代码为真源」维护(当前 `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
+189 -109
View File
@@ -1,73 +1,115 @@
# Cyrene v2 — 昔涟
> 自托管的「人格化通用智能体运行时」。一个 Go 单二进制,默认以「全能小女友」的人格陪伴你;在聊天/开发/家庭/工作等模式间无感切换;如 OpenClaw 般接入聊天平台(**KOOK / 微信 iLink 已接通**,QQ / Telegram 待接通);各人格独立记忆与情绪、共享同一套能力。
> 自托管的「人格化通用智能体运行时」。一个 Go 单二进制,默认以「全能小女友」的人格陪伴你;在聊天/开发/家庭/工作等模式间无感切换;如 OpenClaw 般接入聊天平台(**KOOK / 微信 iLink 已接通**,QQ OneBot v11 链路已打通但暂不连,Telegram 待凭据);各人格独立记忆与情绪、共享同一套能力。
**状态**:按里程碑 M0–M12 推进中;每一项能力以「当前能力」三档清单(已实现 / 降级 / 未实现)为准,**不作整体完成宣称**。 | **分支**:`dev` | 设计文档见 `DevDocs/00-overview.md`
**状态**:M0–M12 里程碑主干已落地,现处于「后 M12 增量切片」阶段。每一项能力以本文的三档清单(已实现 / 需凭据·部分 / 未实现)为准,**不作整体完成宣称**;真实缺口见「已知缺口与路线图」。 | **分支**:`dev` | 设计文档见 `DevDocs/00-overview.md`
---
## 当前能力(按真实状态分三档,不虚标)
> 状态以实际开发进度为准;标注「降级 / 占位」的项依赖外部凭据 / endpoint 才能达到完整形态。
> 状态以**代码 + 真机实测**为准。标注「需凭据」的项依赖外部凭据 / endpoint 才能达到完整形态;「未实现」一律如实写出,不当「已修」。
### ✅ 已实现
- **单二进制、进程内协作** — 所有服务同进程打包,仅 gateway `:8080` 对外;模块间走进程内接口 + 事件总线,**零跨进程 IPC**
- **人格化陪伴** — 多人格 / 情绪状态机 / 好感度;per-persona 记忆隔离,能力全局共享
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级)、工具调用(8 轮上限)、子代理编排(生命周期 / 合并 / 上限)、记忆三类作用域 + RAG、会话持久化 / 主·次级会话
- **多平台 IM 渠道插件(已接通)** — KOOK、微信 iLink 已打通;OneBot v11 QQ / Telegram / Webhook 渠道插件均内置、可配置、随二进制分发
- **微信 iLink 扫码绑定 + 自动认主** — 扫码登录(`login-qr` / `login-status`)已打通;绑定确认时**自动把主人 id 登记进 owner 白名单**(热生效,无需人工补录)
- **多渠道主会话同步** — AI 渠道(web / kook / weixin)主人私聊汇聚到主会话,Web 端免刷新镜像;群聊 / 陌生人走独立隔离会话
- **参与度引擎**、**backthink 自主思考**(读写记忆 + 调工具 + 主动推送)、**全链路流程可视化 P1**(`graph_update`)
- **系统设置(4 个可写操作)** — 吊销全部 API key / 轮换 JWT / 备份 / 恢复,后端 + 前端真实操作已接通(owner-only + 审计 + 前端确认弹窗)
- **语境感知 UI** — 同一会话上下文下切换 聊天/开发/家庭/工作/知识/语音/视觉/子代理监控/设置 模式(Miuix 设计语言)
- **超级详尽后台管理面板**(`/admin`)— 15 大模块统一管理(RBAC + 审计)
- **多平台部署** — Linux / Windows / macOS / ARM / Termux;SQLite 默认(PG + pgvector 可选增强,配置后真生效)
- **单二进制、进程内协作** — 所有服务同进程打包,仅网关一个对外端口(默认 `:8080`);模块间走进程内接口 + 事件总线,**零跨进程 IPC**。唯一登记例外:`qq-obv11` 渠道的 OneBot 反连(server)模式可开监听端口,且**必须由配置显式给出 `listen_addr`**,不默认开
- **人格化陪伴** — 多人格(`configs/personas/*.yaml`)/ 情绪状态机 / 好感度;**人格 = 身份**(不限制能力)、per-persona 记忆隔离、能力全局共享
- **通用 Agent 运行时** — 多供应商 LLM(per-role 路由 + 降级:主 → 备 → 本地,本地级默认关)、工具调用(**8 轮上限**)、流式输出、子代理编排(生命周期 / 结果合并 / 并行上限 / 超时 / 防失控)、会话持久化 + 主·次级会话、上下文压缩(预算按模型窗口推导)
- **记忆系统** — **三类作用域隔离**(用户习惯全局共享 / 角色日常他角色只读 / 项目人人读写 + 项目索引分层)、相关性 + 最近度检索、衰减、溯源、RAG;**LLM 异步提取**(每轮落定后后台提取,手工提取兜底);**定期记忆整理**(低峰后台任务,只产候选建议、绝不自动改写);**入库前审查**(D3:新记忆落库前先判定 merge / modify / create / skip,软合并可 `revert` 回滚)
- **durable execution / checkpoint** — 每步落盘快照,**崩溃后可从最新 checkpoint 续跑**(`loadCheckpoint` → `Step+1`;仅「崩溃遗留的在途 run」允许恢复,已落定退出一律重开)
- **IM 渠道插件(内置 5 个,随二进制分发)** — **KOOK** ✅、**微信 iLink** ✅(扫码绑定 + 绑定确认时**自动把主人登记进 owner 白名单**,热生效)、**QQ OneBot v11**(正/反 WS × HTTP,四端组合齐;判主 / 收发 / 语音转写均**真机验证过**,但主人 2026-10-05 因风控决定**暂不连**,代码保留)、**Telegram**(长轮询 / webhook,需 Bot token)、**通用 Webhook**(入站,已挂网关)
- **多渠道主会话同步** — 主人私聊(Web / KOOK / 微信 / QQ)汇聚到主会话,Web 端免刷新镜像;群聊 / 陌生人走独立隔离会话,不写记忆、不入主会话上下文
- **后台思考与主动推送** — 自主思考心跳 + 事件触发(安全域 / 急性事件可穿透安静时段)+ 静默时段降频(`THINK_QUIET_INTERVAL_SEC`)+ 主动推送限频;**自排思考 `request_think`**(她可在一次思考里给自己排下一轮,有窗口次数上限)
- **技能(Skill)** — 目录式 YAML(`configs/skills/<id>/skill.yaml`,内含示例 `daily-brief`);**渐进披露**(清单只给 description,命中才展开正文)+ **工具面收缩**(`allowed-tools` 是能力上限,只收不放);**`Runner.Run` 多步执行可用**(步骤拓扑 / 环检测 / 并行上限 / guardrail 强制点)
- **插件声明的管理页面** — `GET /api/v1/admin/panels`(按角色过滤的接入点快照)+ `POST /api/v1/admin/plugins/{id}/actions/{action}`(**真执行**已登记动作,写动作走 owner/admin + 审计);插件生命周期变化推 WS 帧 `panels_changed`
- **网络搜索** — 原生 **6 家 provider**(SearXNG 本机兜底 / 智谱 / 阿里 IQS / 博查 / 火山·豆包 / 百度千帆)+ 降级链 + **配置面板热重载**(改配置即生效、无需重启;会让 web-search 无可用后端的改动会被回滚并报 `409`)
- **文件 / 文档进对话** — 上传 → 抽取正文 sidecar → 按预算注入上下文;**含 PDF 摄取**;引用计数 + 保留期 GC(仍被引用的文件绝不删)
- **语音** — 云端 **STT / TTS 真实接通**(阿里云百炼 DashScope:`qwen-audio-3.0-asr-flash` / `cosyvoice-v3-flash`),含**声音复刻**入口;IM 入站语音自动转写;**语音落库 + 回放**(消息 DTO `voice` + `GET /api/v1/media/{name}`,支持 Range);不可播格式(QQ AMR)**转码成 wav** 后再落副本
- **浏览器工具** — 自研**出站 CDP**(不发 `Runtime.enable`,减少被识别面);**有头 Chrome + 持久化 profile**(过风控口径);动作 `open` / `screenshot` / `info`(owner-only);**截图落盘后自动进本轮上下文**;插件声明页(B4)已落地
- **MCP 客户端** — 接入官方 MCP SDK(Go);stdio 子进程 / SSE / Streamable HTTP;工具**自动发现并注册**进能力面
- **实时全链路流程图(含时间轴)** — `graph_update` 实时帧 + 落盘 `flow_graphs` 多轮历史 + `GET /api/v1/admin/flow/graph`(最近一轮 / 指定 `turnId` / `lanes[]`)+ `GET /api/v1/admin/flow/timeline`(按时间正序的多轮时间轴,支持 `limit` / `before` 分页 + 保留期信号);**真并行每路各发自己的帧**(不再互相覆盖);Flow 页提供「最近一轮 / 时间轴」两种视图
- **超级详尽后台管理面板**(`/admin`,路径可配)— **18 个静态模块**(仪表盘 / 人格 / 会话 / 记忆 / 知识库 / 插件 / 渠道 / 渠道事件 / IoT / LLM / 自动化 / Agent 监控 / 全链路流程 / 后台思考 / 通知 / 可观测性 / 用户与权限 / 系统设置)+ **插件声明的动态页**,统一 RBAC(`owner` > `admin` > `viewer`)+ 审计
- **系统设置(4 个可写操作)** — 吊销全部 API key / 轮换 JWT / 备份 / 恢复(owner-only + 审计 + 前端确认弹窗)
- **语境感知 UI** — 用户端是**一个共享页面**:单页消息流,每条消息带自己的 mode,气泡按 mode 渲染(`chat` / `code` / `home` / `work` / `docs` / `voice` / `vision` / `agents` / `settings` 共 9 种);统一 **Miuix 设计语言** + PWA
- **多端契约** — 用户侧 REST `/api/v1` + 实时 WS `/ws/chat` + 管理 `/api/v1/admin/*`,**版本化契约 v0.81**(权威文档 `Docs/api-client.md`)
- **多平台部署** — Linux / Windows / macOS / ARM / Termux;**SQLite 默认**(纯 Go、单二进制);PG + pgvector **可选增强**(配置后真走语义 / 向量路径)
### ⚠️ 部分实现(降级 / 占位)
### ⚠️ 部分实现 / 需凭据
- **视觉(真实模型未接通)** — vision 分层路由与推理入口已实现,但**真实视觉 / OCR 模型服务未接入**:需配置 `LLM_VISION_MODEL` 及凭据;无凭据时降级为主模型直接看图 / 视觉·OCR 工具文本描述
- **语音(ASR / TTS 真模型未接通)** — STT→chat→TTS 链路已接,但**真实 ASR / TTS provider 未接入**:cloud STT 端点为占位(空 endpoint → 降级),edge-tts / espeak 为未实现占位;无凭据时全程降级(`silent` 返回空音频)。需配 ASR / TTS endpoint
- **RAG 语义 / 向量检索** — 导入文档当前走 keyword / line 路径(知识库 store 无直属向量索引),向量语义为**优雅降级**;配置 PG + pgvector 增强后真走语义路径
- **视觉** — 分层路由(主模型 → 工具化视觉 / OCR)与**真推理入口已落地**;真实视觉 / OCR 模型服务**需配 `LLM_VISION_MODEL`(或 `LLM_OCR_MODEL`)及凭据**。无凭据时降级为主模型直接看图 / 视觉·OCR 工具文本描述
- **语音的真实双向通话(Web 端)** — 上行链路与 STT/TTS 已通;`voice` 模式的「实时通话」交互仍未接(前端标注 deferred)。`edge-tts` / `espeak` 仍是**未实现占位**(无凭据时走降级链到 `silent`)
- **声音复刻** — 代码路径已实现,但仍**需主人提供公网可访问的参考音频 URL** 方能真建自定义音色
- **网络搜索的 5 家云端 provider** — 除本机 SearXNG 兜底外,智谱 / 阿里 IQS / 博查 / 火山 / 百度千帆**均需自配 API key,且尚未真机实测**(无凭据);中文查询在本机兜底上的效果有限
- **RAG 语义 / 向量检索** — 默认走内置 n-gram / FTS 向量(dim=256,离线可用);配置 `EMBEDDING_API_URL` + PG/pgvector 后走**真语义**路径
- **微信 iLink 绑定依赖微信侧资格** — 扫码协议已对齐官方并打通;仍需微信账号侧具备 iLink bot 绑定资格(账号环境依赖)
- **checkpoint(durable execution)** — 仅有 `saveCheckpoint` 保存、**无恢复 / Resume**(长任务中断不可回放,半壳)
- **QQ OneBot v11** — 代码链路与真机验证都成立,但按主人决定**当前不连**(风控原因);接回只需配置并在面板启用
- **主动推送的设备兜底(Getui)** — 自建推送服务的 webhook 出口已实现(`PUSH_EXPORTER_*`);个推(Getui)**SDK 未接入**,仅预留字段
### ❌ 未实现(当前不提供)
- **QQ(OneBot v11)** / **Telegram** — 渠道插件代码存在,但未接通(需真 OneBot/Bot token)
- **系统设置**:**编辑环境变量 / 重启服务 / 检查更新** — 后端未实现,不提供
- **知识库删除**(`POST /api/v1/admin/rag/delete` 写操作)— 当前返回 `501 not_implemented`
- **全链路 P2** — 并行子代理分支 / 泳道 / 节点实时日志(当前 loop 未在 span 上记录事件,`log[]` 常空)
- **LLM 异步记忆提取** — 仅手工提取接线,LLM 异步提取未接入
- **`Runner.Run` 多步工作流未接进 loop** — 技能进入 loop 的目前只有「清单 / 命中展开正文 / 工具面收缩」;多步工作流执行器(`Runner.Run` / `Runner.RunSkill`)本身可用且有单测,但**模型还不能在对话里直接触发一套多步技能**
- **浏览器 MCP 委派未落地** — `browser` 工具只提供 `open` / `screenshot` / `info`;`click` / `type` / `wait` / `extract` 仅在工具描述里指路,实际需经 MCP 浏览器服务补齐(未接)
- **搜索降级档 B5 未做**、浏览器容器 docker 配方(CJK 字体)**未落盘**、浏览器会话**未挂进程关闭链**
- **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现,不提供
- **自排思考 C2 的写路径(面板可调)** — 自排的**成本可见面(读侧:`?trigger=` 筛选)已落地**;把后台思考调优参数做成**面板可保存**(`SaveBackgroundTuning` + admin 端点)的部分仍在做
- **全链路 P2 剩余** — token / 耗时统计、移动端优化(并行路 / 泳道 / 节点实时日志本身已落地)
- **群聊类能力** — 冷避、@ 窗口期、戳一戳回应等**全部依赖有群聊的平台**;因 QQ 暂不连、微信 iLink 只支持绑定微信号私聊,当前暂无活体群聊对象(队列保留,不删)
- **IM 路由主动消息的行 id 回填(design/19 §6.10-2)** — 需扩 `SendProactive` 返回值并改全部 mock,本期不做
---
## 本地部署(用户侧 · 全流程)
## 快速开始
### 0. 前置
- Go 1.26、Node 22(前端构建用)、Docker(用于 PostgreSQL + pgvector;不用 PG 可仅用 SQLite)。
- **Go 1.26**、**Node 22**(前端构建用)、**Docker**(可选:PG + pgvector;不用 PG 可仅用 SQLite)。
### 1. 构建
### 1. 一键启动(最快,推荐)
```bash
bash scripts/dev-up.sh
# 依次拉起:PG+pgvector → llama.cpp embedding(Qwen3) → 前端 build → cyrene
# 访问 http://localhost:8080(按 .env 的 PORT)
# 前端(必做一次:go:embed 需要 dist;构建脚本会同步到 internal/webui/dist)
cd frontend/web && npm ci && npm run build && cd ../..
# 后端二进制(Windows)
scripts\ctrl.bat build
# Linux / macOS(无 build 子命令,直接 go build)
go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/
```
> `scripts/dev-up.sh` 约定:llama.cpp 放 `~/apps/llama.cpp/`、GGUF 放 `~/.models/`、cyrene 二进制 `~/apps/cyrene/cyrene`。已运行的服务会自动跳过。
### 2. 配置 `.env`(复制 `.env.example`)
> `scripts\ctrl.bat build` 是 Windows 上的权威构建(带 `-ldflags="-s -w" -trimpath`),产物写到仓库根 `cyrene.exe`。
> **换二进制**:先 `build` 到位,再**一次** `restart`(不留空窗)。
> **纯前端改动不需要重启后端**:运行时**优先读磁盘 `frontend/web/dist`**(存在即覆盖内嵌 dist),刷新浏览器即可。
### 2. 启停(本地服务控制)
```bash
# 认证(JWT_SECRET 必填 ≥32)
# Windows
scripts\ctrl.bat start # 启动(无参 = start;已在跑则跳过)
scripts\ctrl.bat stop # 停止(先优雅、后强杀)
scripts\ctrl.bat restart # 停止 + 启动
scripts\ctrl.bat status # 端口 + PID + /api/v1/health
scripts\ctrl.bat build # 只构建二进制
# Linux / macOS(等价,无 build)
bash scripts/ctrl.sh start|stop|restart|status
```
> 端口取 `.env` 的 `PORT`,缺省回落 `10080`。日志 `%USERPROFILE%\cy.log`(每次启动前把上一份归档为 `cy.log.<时间戳>.bak`,保留最近 20 份),PID 文件 `%USERPROFILE%\.cyrene.pid`。
> 启动默认**分离进程**(`Start-Process -WindowStyle Hidden`):从 Git Bash 调 `restart` 时请把输出重定向到文件(`> /tmp/cy-restart.log 2>&1 < /dev/null`),否则 bash 会等管道 EOF 而「不返回」;**判定成功要看「新 PID + health ok + 启动日志」,不是看命令退没退出**。
### 3. 配置 `.env`(复制 `.env.example`)
```bash
# 认证(JWT_SECRET 必填 ≥32,过短启动即 panic)
JWT_SECRET=<≥32 字符随机串>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<你的密码>
# LLM(LLM_PROVIDER 显式声明协议:openai / anthropic)
# LLM(LLM_PROVIDER 显式声明协议:openai / anthropic,默认 openai)
LLM_PROVIDER=anthropic
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
ANTHROPIC_API_KEY=<key>
ANTHROPIC_MODEL=deepseek-flash
# 或 OpenAI 兼容:LLM_PROVIDER=openai + LLM_BASE_URL/API_KEY/MODEL
# 或 OpenAI 兼容:LLM_PROVIDER=openai + LLM_BASE_URL / LLM_API_KEY / LLM_MODEL
# 数据库(可选增强:PG + pgvector 向量检索;不配则用 SQLite)
DB_PG_URL=postgres://cyrene:cyrene_pass@localhost:5432/cyrene_ai?sslmode=disable
@@ -77,82 +119,138 @@ EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
EMBEDDING_MODEL=Qwen3-Embedding-0.6B
```
### 3. 数据库(PG + pgvector,Docker)
### 4. 可选依赖
```bash
# PG + pgvector(可选增强:向量索引走这里)
docker compose -f docker-compose.dev.yml up -d pg
# 首次:vector 扩展由应用自动 CREATE(本地 cyrene 用户为 superuser)
```
> 不用 PG 可跳过;SQLite 默认即用。若 Docker 未安装 compose 插件(部分精简版 29.x 只有 `docker run`),
> `scripts/dev-up.sh` 会自动回退 `docker run` 拉起 `cyrene-pg`,无需手动处理。
### 4. 本地 embedding(llama.cpp + Qwen3 GGUF,走 Vulkan/GPU)
```bash
# 一次准备(llama.cpp 放 ~/apps/llama.cpp/,模型放 ~/.models/;勿用 /tmp)
mkdir -p ~/apps/llama.cpp ~/.models
# llama.cpp release(Vulkan)解压到 ~/apps/llama.cpp/
# Qwen3-Embedding-0.6B-Q8_0.gguf(~639MB,ModelScope 下载)→ ~/.models/
chmod 644 ~/.models/Qwen3-Embedding-0.6B-Q8_0.gguf
# 本地 embedding(可选:真语义;llama.cpp + Qwen3 GGUF,CPU/GPU)
scripts\embed.bat start|stop|status # Linux / macOS:见 embed.bat 内注释自行起 llama-server
# 启动
~/apps/llama.cpp/llama-server -m ~/.models/Qwen3-Embedding-0.6B-Q8_0.gguf \
--embedding -ngl 99 --host 127.0.0.1 --port 11434 &
# 本机 SearXNG 兜底搜索(可选;配置见 scripts/searxng/settings.yml)
```
> **模型来源**:HuggingFace 国内不可访问,请从 **ModelScope(魔搭)** 下载 `Qwen3-Embedding-0.6B-Q8_0.gguf`。llama.cpp 只认 **GGUF**(`model.safetensors` 是 transformers/HF 用的,不能直接喂)。
### 5. 构建并启动(单二进制,go:embed 前端)
```bash
cd frontend/web && npm ci && npm run build && cd ../.. # 产 dist(含拷到 internal/webui/dist)
go build -ldflags="-s -w" -trimpath -o cyrene ./cmd/cyrene/
./cyrene # 访问 http://localhost:8080(按 .env 的 PORT)
```
> `scripts/dev-up.sh` 是 Linux / macOS 上的**一键全流程**(PG → llama.cpp embedding → 前端 build → cyrene),约定 llama.cpp 放 `~/apps/llama.cpp/`、GGUF 放 `~/.models/`、二进制 `~/apps/cyrene/cyrene`;已运行的服务自动跳过。
### 5. 访问
- 用户端 SPA:`http://localhost:8080`(按 `.env` 的 `PORT`)
- 管理面板:`http://localhost:8080/<ADMIN_PATH>`(`ADMIN_PATH` 默认 `admin`;REST/admin API 仍是 `/api/v1/admin/*`,安全靠 JWT + 角色门)
### 6. 服务端口 / 路径速查
| 服务 | 地址 / 路径 | 说明 |
|------|------------|------|
| Cyrene 网关 | http://localhost:8080(`.env:PORT`) | SPA + REST/WS + 面板 `/admin` |
| PG + pgvector | `localhost:5432`(docker `cyrene-pg`) | 向量索引在 `cyrene_ai` |
| llama.cpp embedding | `http://localhost:11434/v1/embeddings` | Qwen3,Vulkan |
| Cyrene 网关 | `http://localhost:8080`(`.env:PORT`) | SPA + REST/WS + 面板 |
| PG + pgvector | `localhost:5432`(docker `cyrene-pg`) | 可选;向量索引在 `cyrene_ai` |
| llama.cpp embedding | `http://localhost:11434/v1/embeddings` | 可选;Qwen3 |
---
### 7. 注意事项
- **勿用 `/tmp` 放二进制/模型**(tmpfs 在内存里,堆多了会炸);一律 `~/apps/`、`~/.models/`。
- `EMBEDDING_API_URL` **必须是完整 `/v1/embeddings`**(不是 base),否则 404。
- **切换 embedding 模型会改维度**(Qwen3=1024,n-gram=256)→ 换模型前清一次向量表:`docker exec cyrene-pg psql -U cyrene -d cyrene_ai -c "TRUNCATE vec_items;"`(懒创建的真向量表)。
- LLM 用 anthropic 协议时填 `ANTHROPIC_*`;用 openai 兼容时填 `LLM_*`。`LLM_PROVIDER` 二选一。
## 配置
### `.env` 已冻结(最小化可跑,不加 / 不改 / 不删)
`.env` 只保证「**最小化功能可跑**」的引导面,**不是配置面**。主人 2026-10-06 定红线:
- **基础键已经定好,以后不准改**:不加、不改、不删;模板 `.env.example` 与 `.env` 同进退。
- **新配置一律不进 `.env`**:调优 / 门槛 / 上限 / 开关类参数走 **`$DATA_DIR/config/**`**(管理面板可改、热重载)+ 代码内默认值。
- 既有基础键与既有 `env:` tag **保持现状**(不迁移、不改默认值、不删键)。
> 想要调优项请走面板(如「LLM 模型」页写 `models.json`、「渠道管理」页写 `channels.json`、插件配置写 `$DATA_DIR/config/plugins/<plugin-id>.json`),或直接改 `$DATA_DIR/config/**` 下的文件。
> 口径全文见 `DevDocs/design/13 §2.1`;`AGENTS.md §10` 亦载明。
### 数据目录 `DATA_DIR`
默认 `./data`;显式写 `platform`(保留字哨兵)则走平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`)。
---
## Embedding 模型推荐
> 用于长记忆/知识库**语义向量检索**(默认不配 = 内置 n-gram 向量,dim=256,离线但质量一般)。
> 配了 `EMBEDDING_API_URL/MODEL` 后走**真语义**。维度 = 模型 dim,与向量索引一致;**切换模型会改维度,需清空旧向量数据**。
> 用于长记忆 / 知识库的**语义向量检索**(默认不配 = 内置 n-gram 向量,dim=256,离线但质量一般)。
> 配了 `EMBEDDING_API_URL` / `EMBEDDING_MODEL` 后走**真语义**。维度 = 模型 dim,与向量索引一致;**切换模型会改维度,需清空旧向量数据**。
| 模型 | dim | 大小 | 推理内存 | 质量 | 建议 |
|------|-----|------|---------|------|------|
| **Qwen3-Embedding-0.6B** | 1024 | Q8_0 **639MB** / f16 1.2GB | ~0.7–1GB | ★★★★★(多语言、2025 新、MTEB 榜前列) | ⭐ **首选**(ModelScope 国内可下 + GGUF;Q8_0 639MB 契合低内存) |
| **Qwen3-Embedding-0.6B** | 1024 | Q8_0 **639MB** / f16 1.2GB | ~0.7–1GB | ★★★★★(多语言、2025 新、MTEB 榜前列) | ⭐ **首选**(ModelScope 国内可下 + GGUF) |
| **bge-m3** | 1024 | ~2.2GB(fp16) / ~1.2GB(Q4) | ~2–3GB | ★★★★★(跨语言标杆) | 内存 / GPU 充裕时可用 |
| **bge-small-zh-v1.5** | 512 | ~30–100MB | ~几十MB | ★★★★ | 极轻量备选 |
| nomic-embed-text | 768 | ~270MB | ~400–600MB | ★★★☆ | 可 |
| mxbai-embed-large | 768 | ~350–670MB | ~0.8–1GB | ★★★★ | 可 |
> **首选说明**:**Qwen3-Embedding-0.6B**(阿里通义 2025-06 开源,覆盖 100+ 语言,MTEB 多语言榜前列)——**有 GGUF**、**ModelScope(魔搭)国内直连可下**(HuggingFace 在国内已不可访问);`Q8_0` 仅 **639MB**,即使内存紧张(如 ~3GB 可用、无独立显存的 UMA GPU)也能跑,且质量≈f16。比 bge-m3 更新、比 bge-small 强。若内存非常宽裕再考虑 bge-m3。
> **首选说明**:**Qwen3-Embedding-0.6B**(阿里通义开源,覆盖 100+ 语言)——**有 GGUF**、**ModelScope(魔搭)国内直连可下**(HuggingFace 在国内已不可访问);`Q8_0` 仅 **639MB**,内存紧张(如 ~3GB 可用、无独立显存的 UMA GPU)也能跑,质量≈f16。
>
> **llama.cpp 能替代 Ollama 吗?** 能,且更省内存。`llama-server`(服务模式)暴露 **OpenAI 兼容 `/v1/embeddings`**:
> ```bash
> ./llama-server -m Qwen3-Embedding-0.6B-Q8_0.gguf --embeddings -ngl 99 --host 127.0.0.1 --port 11434
> # 然后 EMBEDDING_API_URL=http://localhost:11434/v1/embeddings 照用(dim=1024;切换前清一次向量表)
> ```
> - **Ollama**:模型管理方便(`pull`)、开箱即用,但常驻偏重。
> - **llama.cpp**:单文件更轻、可离线自制 GGUF;但模型要自己下 / 转(无 registry)。**只认 GGUF**,`model.safetensors` 不能直接喂。
`.env` 示例(本地 Ollama / llama.cpp):
```bash
# EMBEDDING_API_URL 必须是【完整 /embeddings 端点】(不是 base)
EMBEDDING_API_URL=http://localhost:11434/v1/embeddings
EMBEDDING_MODEL=Qwen3-Embedding-0.6B
# EMBEDDING_API_KEY=
### 注意事项
- **勿用 `/tmp` 放二进制 / 模型**(tmpfs 在内存里);一律 `~/apps/`、`~/.models/`。
- `EMBEDDING_API_URL` **必须是完整 `/v1/embeddings`**(不是 base),否则 404。
- **切换 embedding 模型会改维度**(Qwen3=1024,n-gram=256)→ 换模型前清一次向量表:`docker exec cyrene-pg psql -U cyrene -d cyrene_ai -c "TRUNCATE vec_items;"`。
- LLM 用 anthropic 协议时填 `ANTHROPIC_*`;用 openai 兼容时填 `LLM_*`。`LLM_PROVIDER` 二选一。
---
## 目录结构
```
Cyrene_v2/
├── cmd/cyrene/ # 唯一入口(wire_*.go 组合根:把各子系统接线 + go:embed 前端)
├── internal/ # 进程内模块(不对外)
│ ├── config/ # .env 加载 + $DATA_DIR/config/** 可变源(面板一等来源)
│ ├── logger/ platform/ db/ bus/ errors/ auth/ user/ # 地基
│ ├── runtime/ # 核心运行时:model / agent / loop / tool / guardrail / trace /
│ │ # persist(checkpoint) / memory / embedding / multimodal /
│ │ # orchestration / exec / task / flowgraph / documents / voice / capacity
│ ├── persona/ capability/ channel/ iot/ urgency/ contract/
│ ├── services/ # gateway(REST/WS/admin) / automations / backthink / approvalreview / channelbridge
│ └── webui/ # 内嵌前端 dist(go:embed)
├── plugins/ # 一等插件:tools(basic/io/browser) + channels(kook/qq_obv11/telegram/webhook/weixin_ilink)
├── pkg/plugins/sdk/ # 插件 SDK(第三方接入)
├── configs/ # 配置模板:personas/ + users/(主人档案)+ skills/(示例技能)
├── frontend/web/ # React SPA(单页消息流 + mode 渲染器)+ 管理面板(Miuix);构建产物 dist/ 供磁盘热覆盖
├── Docs/ # 用户文档(进 git):api-client.md(客户端 API 契约)+ archive/
├── DevDocs/ # 开发文档(**不进 git**,本地锚点:设计 / 时间线 / 工作小结 / 审计)
├── data/ # 运行时数据(DATA_DIR;SQLite / uploads / 面板写入的 *.json)
├── scripts/ # ctrl.bat / ctrl.sh / embed.bat / dev-up.sh / searxng/
└── services/ # 预留(外部服务扩展位)
```
**llama.cpp 能替代 Ollama 吗?** —— 能,且更省内存。llama.cpp 的 `llama-server`(服务模式)同样暴露 **OpenAI 兼容 `/v1/embeddings`**,用单二进制 + 一个 GGUF:
```bash
./llama-server -m Qwen3-Embedding-0.6B-Q8_0.gguf --embedding -ngl 99 --port 11434
# 然后 EMBEDDING_API_URL=http://localhost:11434/v1/embeddings 照用(dim=1024;切换前清一次向量表)
```
- **Ollama**:模型管理方便(`pull`)、开箱即用,但常驻偏重。
- **llama.cpp**:单文件更轻(内存更省,适合资源受限 / 单机部署)、可离线自制 GGUF;但模型要自己下/转(无 registry)。注意:**llama.cpp 只认 GGUF**,`model.safetensors` 是 transformers/HF 用的,不能直接喂 llama.cpp。
- 二者对上层代码**等价**(都是 OpenAI 兼容 embedding 端点);资源受限时 llama.cpp 更省内存,追求易用则选 Ollama。
> `Docs/`(大写 D)**进 git**;`DevDocs/` **被 gitignore,不进 git**。`*.exe`、`dist/`、`data/`、`.env` 等亦不入库(见 `.gitignore`)。
---
## 已知缺口与路线图
> **不美化**:以下是**当前真做不了 / 没做完**的事。
**核心运行时**
1. 🔴 **`Runner.Run` 多步工作流未接进 loop** — 技能的多步执行器可用且有单测,但**模型还不能在对话里触发一整套多步技能**(现只到「清单 / 命中展开正文 / 工具面收缩」)。
2. **自排思考 C2 的写路径未做** — 自排的成本可见读侧(`?trigger=` 筛选)已落地,面板可保存的后台思考调优(`SaveBackgroundTuning` + admin 端点)在做。
3. **全链路 P2 剩余** — token / 耗时统计、移动端优化。
**浏览器**
4. 🔴 **MCP 委派未落地** — `click` / `type` / `wait` / `extract` 尚未接通(只在工具描述里指路)。
5. **搜索降级档 B5 未做**;容器 docker 配方(CJK 字体)未落盘;浏览器会话未挂进程关闭链。
**渠道**
6. **QQ OneBot v11 暂不连**(主人 2026-10-05 因风控决定);**Telegram 需 Bot token**。群聊类能力(冷避 / @ 窗口 / 戳一戳)因此暂无活体平台可验。
7. **搜索 5 家云端 provider 未真机实测**(无 key);微信 iLink 依赖微信侧绑定资格;声音复刻需公网参考音频 URL。
8. **视觉 / OCR 真模型需凭据**;`edge-tts` / `espeak` 仍是占位。
**治理 / 其他**
9. **系统设置**:编辑环境变量 / 重启服务 / 检查更新 — 后端未实现。
10. **Getui(个推)兜底未接入**(SDK 未接);待主人自建推送服务。
11. **部分真机端到端未验**(如实登记):时间轴真机面板联调、搜索 provider 热重载真机端到端、AMR 落副本生产端到端、`graph_update` 真并行真机端到端、`design/19 §6.8` 的 Q1–Q4。
12. **Android 客户端**另会话推进(跨仓协同事项见 `DevDocs`,不入本仓库)。
---
@@ -160,30 +258,12 @@ EMBEDDING_MODEL=Qwen3-Embedding-0.6B
| 层 | 技术 |
|----|------|
| 后端 | Go 1.26(单二进制运行时)、进程内总线(可换 NATS)、slog(日志带 `source` 来源标注) |
| 后端 | Go 1.26(单二进制运行时)、进程内事件总线(API 刻意对齐 NATS,便于日后换出进程 transport)、`slog`(日志带 `source` 来源标注) |
| 前端 | React 19、TypeScript、Vite 6、Tailwind 4、Zustand 5、PWA(Miuix 设计语言) |
| 数据库 | **SQLite 默认**(纯 Go、单二进制);PG + pgvector 可选增强(配置后真生效) |
| 多模态 | vision(分层路由:主/视觉/OCR;真模型未接通,无凭据降级主模型)、async voice(STT→agent→TTS 降级链;真 ASR/TTS 未接通,无凭据降级 silent) |
| LLM | 多供应商(OpenAI 兼容/订阅/本地/网关)、per-role 路由 + 降级 |
---
## 目录
```
Cyrene_v2/
├── cmd/cyrene/ # 唯一入口(mapping 全部子系统 + go:embed 前端)
├── internal/ # config/logger/platform/db/bus/errors/auth/user/
│ # runtime(核心: model/agent/loop/tool/guardrail/trace/persist/
│ # memory/embedding/multimodal/orchestration/exec/task)
│ # persona/capability/channel/services(gateway/automations/backthink)/iot/webui
├── plugins/ # tools(basic/io) + channels(qq_obv11/weixin_ilink/telegram/webhook)
├── pkg/plugins/sdk/ # 插件 SDK(第三方)
├── frontend/web/ # React SPA(mode 系统)+ /admin 面板(Miuix)
├── Docs/ # 用户文档(含 api-client.md 客户端 API 契约)
├── DevDocs/ # 开发文档(本地,不入库)
└── configs/ # 配置模板(personas/ + users/ 主人档案)
```
| 多模态 | vision(分层路由:主 / 视觉 / OCR;真模型需凭据,无凭据降级主模型)、voice(DashScope STT/TTS 真实接通 + 降级链;无凭据降级 `silent`) |
| LLM | 多供应商(OpenAI 兼容 / Anthropic / 本地 / 网关)、per-role 路由 + 降级(主 → 备 → 本地) |
| 插件 / 协议 | 进程内插件 SDK(`pkg/plugins/sdk`)、官方 MCP SDK(stdio / SSE / Streamable HTTP) |
---
@@ -191,10 +271,10 @@ Cyrene_v2/
| 文档 | 位置 | 说明 |
|------|------|------|
| 架构总览/重构蓝图 | `DevDocs/00-overview.md` | 产品定位、架构、渠道插件、后台面板、阶段计划 |
| 开发约束 | `DevDocs/02-development-constraints.md` | 编码规范 + 性能要求 |
| **客户端 API** | `Docs/api-client.md` | 跨平台客户端 API 契约(REST/WS/admin,版本化) |
| 开发铁律 | `AGENTS.md` | 每会话自动注入;含全球红线(单二进制/身份识别/前端 Miuix 等) |
| 架构总览 / 重构蓝图 | `DevDocs/00-overview.md` | 产品定位、架构、渠道插件、后台面板、阶段计划(**本地,不进 git**) |
| 开发约束 | `DevDocs/02-development-constraints.md` | 编码规范 + 性能要求(**本地**) |
| **客户端 API** | `Docs/api-client.md` | 跨平台客户端 API 契约(REST / WS / admin,版本化,当前 **v0.81**) |
| 开发铁律 | `AGENTS.md` | 每会话自动注入;含全球红线(单二进制 / 身份识别 / 前端 Miuix 等) |
---
+113 -15
View File
@@ -14,10 +14,10 @@ package main
// - 「图 → 内容部件」的路由与裁切由 `internal/runtime/multimodal/vision/shot_test.go` 覆盖;
// - 本文件把两者接起来,并且**断言模型侧确实拿到 image 部件**(判据 2 的原文要求)。
//
// ⚠️ **接线状态(如实)**:本文件把 `a.browserShotTransform(...)` 装进 `loop.Options`
// 来验它;生产是否装上由 `wire_runtime.go` 的一行决定(本片无权改那个文件,见
// `wire_browser_context.go` 的接线状态注释)。`TestWireRuntime_WiresBrowserShotTransform`
// 会盯着那一行:装上之前它 Skip 并写明要加什么,装上之后它就是正向断言。
// 生产侧的**两条挂点**都由本文件盯住(它们删掉之后都没有任何运行期症状):
// `TestWireRuntime_WiresBrowserShotTransform` 钉 `wire_runtime.go` 的 `TransformContext`;
// `TestWireFrontend_WiresBrowserPanel` 钉 `wire_frontend.go` 的 `wireBrowserPanel` 调用
// (`TestWirePanelHub_DeclaresBrowserPanel` 是它的**行为侧**配偶:真装配一遍再读回目录)。
import (
"bytes"
@@ -30,7 +30,10 @@ import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
@@ -113,7 +116,8 @@ func newB3Fixture(t *testing.T, sighted bool) *b3Fixture {
shot: shotPath,
loop: loop.New(loop.Options{
Agent: ag,
// ← 生产接线就是这一行(见 wire_runtime.go 的接线状态注释)。
// ← 与生产接线逐字同一行(wire_runtime.go 的 loop.Options.TransformContext),
// 由 TestWireRuntime_WiresBrowserShotTransform 源码级盯住。
TransformContext: a.browserShotTransform(resolver),
OnStream: func(model.StreamEvent) {},
}),
@@ -194,21 +198,95 @@ func TestBrowserShotClosure_ShotIsConsumedOnce(t *testing.T) {
}
}
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行。
// TestWireRuntime_WiresBrowserShotTransform 盯住生产接线那一行(B3 的生产挂点)。
//
// 装上之前**Skip 并写明要加什么**(不是失败:本片(Y4)的文件面不含 wire_runtime.go,
// 那一行由主会话落地)。装上之后本用例就是正向断言 —— 它拦的是「以后有人把那行删了」,
// 因为删掉之后**没有任何运行期症状**:编译照过、回合照跑,只是截图不再进上下文。
// 它拦的是「以后有人把那行删了」:删掉之后**没有任何运行期症状**——编译照过、回合照跑,
// 只是截图不再进上下文。因此断言的是**一整行字段赋值**(行首到行尾),被注释掉的
// (`// TransformContext: …`)或写在别处的同名字符串都不会让它通过。
func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
if !strings.Contains(src, "browserShotTransform(") {
t.Skip("接线未应用:请在 wire_runtime.go 的 loop.New(loop.Options{…}) 里加 " +
"`TransformContext: a.browserShotTransform(visionResolver),`" +
"(B3 闭环的生产挂点;本片无权改该文件)")
const assignment = "TransformContext: a.browserShotTransform(visionResolver),"
if !hasNormalizedCodeLine(src, assignment) {
t.Fatalf("wire_runtime.go 的 loop.Options 里找不到 %q —— B3 截图闭环的生产挂点缺失:"+
"截图照落盘、回合照跑,只是**一张图都不会进上下文**", assignment)
}
if !strings.Contains(src, "TransformContext:") {
t.Fatal("wire_runtime.go 里出现了 browserShotTransform,但没挂在 TransformContext 上")
// 钩子捕获的就是 `visionResolver`,因此它必须在 loop.New 之前建好;顺序反过来装上的
// 就是一个 nil 解析器(ShotAttacher 为 nil),截图静默不送——同样是**零症状**的坏法。
idxResolver := strings.Index(src, "visionResolver := vision.NewMessageResolver(")
idxLoop := strings.Index(src, "a.loop = loop.New(loop.Options{")
if idxResolver < 0 || idxLoop < 0 || idxResolver > idxLoop {
t.Fatalf("visionResolver 与 loop.New 的先后不对(偏移 %d vs %d):钩子必须在解析器**之后**装配",
idxResolver, idxLoop)
}
}
// TestWireFrontend_WiresBrowserPanel 盯住 B4 的生产挂点:`wirePanelHub` 必须在
// `declareBuiltinPanels` 之后调用 `wireBrowserPanel`。
//
// 与上面那条同族——删掉之后**没有任何编译期或运行期症状**:只是面板目录里没有 browser 页、
// 六个动作全回 501(`GET /api/v1/admin/panels` 少一条、点击全部 501),而整树测试照绿。
// 断言同样锚定**一整行调用**,注释掉那一行不会通过。
func TestWireFrontend_WiresBrowserPanel(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_frontend.go")
const call = "wireBrowserPanel(hub, a, a.log)"
if !hasNormalizedCodeLine(src, call) {
t.Fatalf("wire_frontend.go 的 wirePanelHub 里找不到 %q —— B4 的 browser 面板声明"+
"与六个动作都进不了目录(面板无 browser 页、动作全 501)", call)
}
// 位置:声明在前、登记在后是 PLG-2 的硬要求(登记期会核对有没有对应声明)。这里钉住
// 它**在 wirePanelHub 内**且**在 declareBuiltinPanels 之后**,防止被挪到别处或提前。
idxDeclare := strings.Index(src, "declareBuiltinPanels(hub, log)")
idxBrowser := strings.Index(src, call)
if idxDeclare < 0 || idxBrowser < idxDeclare {
t.Fatalf("wireBrowserPanel 的调用位置不对(declareBuiltinPanels 偏移 %d,调用偏移 %d):"+
"它必须在声明之后(wirePanelHub 内部)", idxDeclare, idxBrowser)
}
}
// TestWirePanelHub_DeclaresBrowserPanel 是上面那条源码级断言的**行为侧配偶**:真的走一遍
// `wirePanelHub` 装配,再从面板中枢里读回目录与动作白名单 —— 声明在、六个动作也登记上,
// 才算 B4 的接线成立。
//
// 两条合起来才够:源码级那条拦「那一行被删掉」,这条拦「那一行还在、但调用链断了」
// (wireBrowserPanel 被改成早返回、hub 传错、或声明/登记顺序被调反 ⇒ 只会得到一串 WARN)。
// 这台机器上**没有**浏览器也没关系:不可用时面板仍要如实声明(`available=false`),
// 这正是 design/09 §5.2.1 的「该面板不注册 + 显式报错」之外的正常档。
func TestWirePanelHub_DeclaresBrowserPanel(t *testing.T) {
t.Parallel()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
defer mgr.Close(context.Background())
b := bus.New()
defer b.Close()
wirePanelHub(&app{cfg: &config.Config{DataDir: t.TempDir()}, plugins: mgr, bus: b, log: testLog()})
hub := mgr.Panels()
if hub == nil {
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
}
entries, err := hub.List(auth.RoleOwner)
if err != nil {
t.Fatalf("List: %v", err)
}
found := false
for _, e := range entries {
if e.PluginID == browsertools.PanelPluginID && e.Panel.ID == browsertools.PanelIDSettings {
found = true
}
}
if !found {
t.Fatalf("面板目录里没有 browser/settings(目录=%+v):B4 的挂点没生效", entries)
}
for _, action := range []string{
browsertools.ActionState, browsertools.ActionFingerprint, browsertools.ActionShots,
browsertools.ActionShoot, browsertools.ActionCheck, browsertools.ActionSaveConfig,
} {
if _, ok := hub.Action(browsertools.PanelPluginID, action); !ok {
t.Errorf("动作 %q 没进白名单(声明有、没人执行 ⇒ 面板点下去只会回 501)", action)
}
}
}
@@ -216,6 +294,26 @@ func TestWireRuntime_WiresBrowserShotTransform(t *testing.T) {
* 断言助手
* ------------------------------------------------------------------------- */
// hasNormalizedCodeLine 报告源码里是否存在这样**一行代码**:去掉行首空白、把连续的空白
// 折叠成一个空格之后,与 want 逐字相等。被 `//` 注释掉的行一律不算。
//
// 为什么要折叠空白:字段赋值在 gofmt 里按块对齐(`TransformContext:` 与
// `DocumentMaxInjectChars:` 同一组时中间是 7 个空格,格式而非语义),逐字节比对整行会
// 在**一次无关的字段增删触发重新对齐**时误红;而折叠后逐行相等仍然能挡住
// 「注释掉那一行」与「别处的同名字符串」这两种假绿。
func hasNormalizedCodeLine(src, want string) bool {
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
if strings.Join(strings.Fields(line), " ") == want {
return true
}
}
return false
}
// findImagePart 返回请求里的第一个 image 部件。
func findImagePart(msgs []model.Message) (model.Content, bool) {
for _, m := range msgs {
+11
View File
@@ -203,6 +203,17 @@ func wirePanelHub(a *app) {
})
a.plugins.AttachPanels(hub)
declareBuiltinPanels(hub, log)
// browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记
// 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。
//
// 为什么要挡一下 `cfg == nil`:浏览器面板的配置路径与表单默认值**全部**由
// `$DATA_DIR` 推出(wire_browser_panel.go 里 `resolveDataDir(a.cfg)`),而部分只读
// 夹具(wire_panels_test.go / wire_plugin_actions_test.go 只填了 plugins/bus/log)
// 没有配置 ⇒ 直调会解空指针。生产路径上 wireFrontend 跑在 config.Load 之后,
// `a.cfg` 恒非 nil,因此这一支只为「不完整夹具」存在(同 wireBrowser 的取舍)。
if a.cfg != nil {
wireBrowserPanel(hub, a, a.log)
}
// 登记动作处理器必须在**声明之后**:登记期会核对「登记的处理器有没有对应声明」,
// 顺序反过来只会得到一串「声明还没到」的 WARN。
// 日志来源另标一处(动作接线 ≠ 面板中枢):`source=cmd/cyrene/wire_plugin_actions`
+27 -2
View File
@@ -1,7 +1,8 @@
package main
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志,以及
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源)。
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
// 以及 design/28 §8 B5 的**浏览器降级档开关**(`bodyFallbackOption`,当前 = 关)。
//
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
@@ -90,10 +91,34 @@ func (a *app) ioToolOptions() []iotools.Option {
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),
// B5 的降级档开关(当前 = **关**,理由见 bodyFallbackOption 的文档)。
a.bodyFallbackOption(),
}
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
}
// bodyFallbackOption 是 design/28 §8 B5 的**开关点**:web-fetch 取正文时,直连失败 /
// 判定为动态页要不要改走**浏览器渲染**(`iotools.WithBrowserFallback`,默认关)。
//
// **当前结论 = 关**(本函数返回的就是「不注入渲染器」)。理由在能力面上,不在这个开关上:
// `internal/capability/browser` 目前只导出 `Open`(导航,回报 url/title/readyState)、
// `Screenshot`、`Fingerprint` —— **没有**任何「取页面正文」的入口,`plugins/tools/browser`
// 的工具动作也只有 `open`/`screenshot`/`info`。没有渲染器可注入,这一档打开也只可能
// 如实失败 —— 那不是「降级档」,是给每次取正文加一条注定失败的噪声。
//
// 打开它要等能力面先补一个正文抽取入口(形如
// `Manager.RenderText(ctx, url) (RenderedText, error)`,内部走既有的隔离世界求值 +
// 同一份 `io.ValidateRequestURL` 判定源),届时把这里换成注入该适配器即可(一行)。
// 在此之前**不假装可用** —— 与 web-search「拿不到后端就不注册」同一口径
// (design/28 §7 判据 1、§7 判据 4 的诚实标注)。
//
// 为什么不在这里现场探测浏览器:`ioToolOptions()` 是**只读装配**,探测的副作用
// (起进程 / 连 CDP 端点)不属于「取一份 io 选项」这件事 —— 口径见 `wire_browser.go`
// 顶部第 3 条,且 `wire_browser_test.go` 钉住它不许在这里探测。
func (a *app) bodyFallbackOption() iotools.Option {
return iotools.WithBrowserFallback(nil)
}
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
// 重建工具,见 websearch_reload.go)。
//
+7
View File
@@ -344,6 +344,13 @@ func (a *app) wireRuntime() error {
Thinking: thinking,
ReasoningEffort: reasoningEffort,
ThinkingShowToUser: thinkingShow,
TransformContext: a.browserShotTransform(visionResolver),
// 截图闭环(design/28 §7 判据 2 / B3):browser 拍照落盘后,由上一行那个钩子在
// **每个模型步之前**把该会话待送的图并入本轮视图(取走即消费)。它只改本轮所见 ⇒
// 截图不进 checkpoint、也不进后台记忆提取的输入面(design/28 §4 第 3 条)。
// 钩子捕获的就是上面那个 visionResolver,所以这一行必须在它之后;装配细节与
// 「删掉它没有任何运行期症状」这条理由见 wire_browser_context.go。
//
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
// (file 的 read 自主、write 代审),见 toolAdmissionHooks。
Hooks: a.toolAdmissionHooks(approvalGate),
+22 -8
View File
@@ -12,8 +12,14 @@ import (
// 客户端回看一条回复时读的是 `toolCalls[].resultSummary`(图节点 `props.result_summary`,
// 见 gateway/history_toolcalls.go),实时帧读的就是这里的产物。
//
// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具返回值只在这一层存在;
// 网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文,
// 🔴 **失败也是结果**:回看面对一次失败的调用回的是图上的 `error` 摘要
// (`toolCallResultSummary` 在 `props.result_summary` 缺席时回落 `props.error`),
// 所以实时帧在失败时同样带一份**已脱敏**的错误文本 —— 同一份脱敏路径、同一份上限,
// 客户端不必为「实时」与「回看」写两套判据。键**只在「真的没有结果」时缺席**:
// 调用成功但结果为空,或错误文本本身为空(见 `toolEndFrameSummary`)。
//
// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具的返回值与错误原因只在这一层
// 存在;网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文,
// 脱敏面就被绕过了)。所以摘要只在这里、在**同一份已脱敏文本**上生成,网关只做搬运
// 与截断,绝不反解上游原文。
@@ -33,14 +39,22 @@ func toolFrameResultSummary(result any) string {
return trace.RedactText(renderToolResultSummary(result))
}
// toolEndFrameSummary 是一次调用结束时**要不要带摘要**的取舍点:只有结果真正拿到
// (Invoke 未报错)才给摘要。失败 —— 含工具自身报错、取消、超时(三者对调用方都是
// `Invoke` 返回 error)—— 一律返回空串,帧上该键随之**整键缺席**,绝不用空串顶位,
// 也绝不把错误文本冒充成「结果」:错误的原因属于图与回看面(那里 `status:"error"` 的
// 调用给出的就是图上的 `error` 摘要),实时帧只如实说「这次没拿到结果」。
// toolEndFrameSummary 是一次调用结束时**要不要带摘要**(以及带哪一份)的取舍点。
// **失败也是结果**:调用成功 ⇒ 工具返回值的渲染;失败 —— 含工具自身报错、取消、
// 超时(三者对调用方都是 `Invoke` 返回 error)—— ⇒ **错误文本的脱敏渲染**,因为
// 回看面对失败调用回的就是图上那份 `error` 摘要。两边给同一份文本,「实时」与
// 「回看」不再分叉(这一次调用到底发生了什么,只有一种说法)。
//
// 三条边界:① **超时 / 取消 / 工具报错一视同仁** —— 都是「这次调用有了结论」,错误文本
// 就是结论;失败时 `err` 优先,部分结果不顶掉失败原因(部分结果是过程,失败原因才是结论)。
// ② **脱敏不放宽**:错误文本走的仍是唯一脱敏源 `trace.RedactText`(凭据形状清洗 + 截断),
// 与结果摘要是同一条路径,不新开第二条旁路;下游网关再按与回看面共用的 ≤500 rune 上限兜底
// (loop 侧本就被 trace 收到更短)。③ **只有真的没有结果才留空**:`err == nil` 且结果为空
// (nil / 空串),或 `err` 的文本本身为空时返回空串 ⇒ 帧上该键**整键缺席**,绝不用空串顶位
// (空串会让客户端渲染出一个空的结果区,比没有结果区更糟)。
func toolEndFrameSummary(result any, err error) string {
if err != nil {
return ""
return trace.RedactText(err.Error())
}
return toolFrameResultSummary(result)
}
@@ -9,12 +9,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的四条口径:
// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的五条口径:
// ① 结果真拿到 ⇒ 帧在场,且文本与客户端**回看**同一次调用所见的
// `toolCalls[].resultSummary`(图节点 props.result_summary)**逐字一致**;
// ② 失败/取消/超时 ⇒ 空串(下游据此让键整键缺席),绝不用空串顶位、绝不把错误
// 冒充成结果;③ 凭据形状必须被清洗(与出图同一条 trace.RedactText);
// ④ nil 与空串结果也不产出「看起来像结果」的占位文本。
// ② **失败也是结果**(取消 / 超时同普通报错,一视同仁):帧带错误文本的已脱敏摘要,
// 与回看面回落的图上 `error` 摘要**逐字一致**;③ 只有真的没有结果(结果为空、或错误
// 文本为空)才留空 ⇒ 下游让键整键缺席,绝不用空串顶位;④ 凭据形状必须被清洗
// (成功与失败两条路径共用同一条 trace.RedactText);⑤ nil 与空串结果不产出
// 「看起来像结果」的占位文本。
// toolNodeResultSummary 反读最后一份图快照里 tool 节点的 props.result_summary ——
// 也就是回看面 `toolCalls[].resultSummary` 的**同一份数据源**。没有该 prop 时返回
@@ -35,6 +37,24 @@ func toolNodeResultSummary(t *testing.T, g *trace.FlowGraph) (string, bool) {
return "", false
}
// toolNodeProp 反读最后一份图快照里 tool 节点的某个字符串 prop —— 回看面
// `toolCalls[]` 各项的取值源。没有该 prop(或它不是字符串)时返回 ("", false)。
func toolNodeProp(t *testing.T, g *trace.FlowGraph, key string) (string, bool) {
t.Helper()
if g == nil {
t.Fatal("nil graph snapshot")
}
for _, n := range g.Nodes {
if n.Kind != "tool" {
continue
}
s, ok := n.Props[key].(string)
return s, ok
}
t.Fatalf("图里没有 tool 节点,无法反读 props[%q](nodes=%+v)", key, g.Nodes)
return "", false
}
// TestRun_ToolEndFrameCarriesResultSummary 覆盖成功路径:end 帧带结果摘要,
// start 帧不带(摘要只在结果落定那一刻存在)。
func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) {
@@ -53,11 +73,16 @@ func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) {
}
}
// TestRun_ToolEndFrameOmitsSummaryOnError 覆盖失败/取消/超时路径:三者对循环都是
// `Invoke` 返回 error ⇒ end 帧的 resultSummary **留空**(下游据此让键整键缺席),
// 绝不把错误文本冒充成「结果」。
func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
// TestRun_ToolEndFrameCarriesErrorSummaryOnFailure 覆盖失败/取消/超时路径:三者对循环
// 都是 `Invoke` 返回 error,而**失败也是结果** —— end 帧带的是错误文本的**已脱敏**摘要,
// 且与回看面回落的图上 `error` 摘要**逐字一致**(同一次调用,实时与事后只有一种说法)。
// 失败时若还带回部分结果,摘要仍取错误文本:部分结果是过程,失败原因才是结论。
//
// 错误文本故意带凭据形状:这样「同源钉」在**脱敏**上也灵敏 —— 若哪天失败路径绕过
// `trace.RedactText`,图上那份仍是清洗过的、帧上那份不是,逐字比对会立刻红。
func TestRun_ToolEndFrameCarriesErrorSummaryOnFailure(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
cases := []struct {
name string
result any
@@ -68,7 +93,7 @@ func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, tc.result, errors.New("boom"))
evs, graphs := runToolTurn(t, tc.result, errors.New("boom: token="+secret))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
@@ -76,14 +101,65 @@ func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
if ex[1].Phase != model.ToolPhaseError {
t.Fatalf("end phase = %q, want error(失败路径)", ex[1].Phase)
}
if ex[1].ResultSummary != "" {
t.Fatalf("失败调用的 end 帧带了 %q;失败/取消/超时一律留空,由下游让键缺席",
ex[1].ResultSummary)
got := ex[1].ResultSummary
if got == "" {
t.Fatalf("失败调用的 end 帧没带摘要;失败也是结果,回看面对同一次调用给的是 error 摘要")
}
if !strings.Contains(got, "boom") {
t.Fatalf("end 帧 resultSummary = %q,want 含工具报错文本 boom", got)
}
if strings.Contains(got, secret) {
t.Fatalf("失败帧 resultSummary 里带着凭据原文:%q", got)
}
fromGraph, ok := toolNodeProp(t, graphs[len(graphs)-1], "error")
if !ok || fromGraph == "" {
t.Fatalf("图上 tool 节点没有 error prop (present=%v);回看面会缺席,而实时帧带着错误摘要", ok)
}
if got != fromGraph {
t.Fatalf("实时帧 resultSummary = %q,图上 error = %q;同一份文本才允许两边都出", got, fromGraph)
}
})
}
}
// TestRun_ToolEndFrameErrorSummaryIsRedacted 钉「脱敏不放宽」在失败路径上同样成立:
// 错误文本里带凭据形状时,帧上的摘要必须已经是清洗后的文本 —— 失败不是绕过脱敏的后门。
func TestRun_ToolEndFrameErrorSummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
evs, _ := runToolTurn(t, nil, errors.New("lookup failed: token="+secret))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
got := ex[1].ResultSummary
if strings.Contains(got, secret) {
t.Fatalf("失败帧 resultSummary 里带着凭据原文:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Fatalf("失败帧 resultSummary = %q,want 含 [redacted](与结果摘要同一条清洗路径)", got)
}
}
// TestRun_ToolEndFrameErrorSummaryIsBounded 钉上限:超长错误文本不得整段上帧 ——
// 它走的仍是同一条有界脱敏路径(loop 侧被 trace 收得更短),线上再按与回看面共用的
// ≤500 rune 预算兜底。
func TestRun_ToolEndFrameErrorSummaryIsBounded(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, nil, errors.New(strings.Repeat("详", 2000)))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
got := ex[1].ResultSummary
if got == "" {
t.Fatal("超长失败的 end 帧没带摘要;失败也是结果")
}
if n := len([]rune(got)); n > 500 {
t.Fatalf("失败帧 resultSummary 长度 = %d rune,超过与回看面共用的上限 500", n)
}
}
// TestRun_ToolEndFrameSummaryMatchesGraphResultSummary 是**同源钉**:同一个工具结果,
// 实时帧上的 resultSummary 必须与最后一份图快照里 tool 节点的 props.result_summary
// (回看面 `toolCalls[].resultSummary` 的取值源)逐字相等。它用真建图器(真 span →
@@ -140,7 +216,8 @@ func TestRun_ToolEndFrameSummaryIsRedacted(t *testing.T) {
}
// TestToolFrameResultSummary_EmptyResultsStayEmpty 钉住「绝不产出看起来像结果的占位
// 文本」:nil 与空串结果的摘要必须为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty")。
// 文本」与「只有真的没有结果才缺席」:nil 与空串结果的摘要必须为空,**错误文本为空的
// 失败**同样为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty",也不用空串顶位)。
func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) {
t.Parallel()
for _, v := range []any{nil, ""} {
@@ -151,4 +228,7 @@ func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) {
t.Fatalf("toolEndFrameSummary(%#v, nil) = %q, want 空串", v, got)
}
}
if got := toolEndFrameSummary(nil, errors.New("")); got != "" {
t.Fatalf("toolEndFrameSummary(nil, 空错误) = %q,want 空串(真的没有结果才缺席)", got)
}
}
@@ -3,6 +3,7 @@ package gateway
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -19,7 +20,9 @@ import (
// 安卓端按「键在场 ⇒ 渲染结果区、缺席 ⇒ 只显示工具名与状态」编程,因此这里要同时锁住
// ① 成功路径经**真实生产链路**(真 loop → bus → relay → broadcastStream)到达客户端时
// 在场且文本正确;② 空摘要 ⇒ 键**整键缺席**(绝不回空串顶位);③ 只有 end 帧带它;
// ④ 超长按与回看面**同一个**上限(maxToolCallSummaryRunes)截断,末位 `…`。
// ④ 超长按与回看面**同一个**上限(maxToolCallSummaryRunes)截断,末位 `…`;
// ⑤ **失败也是结果**:失败调用(phase=error)的 end 帧同样带摘要 —— loop 侧给的是
// 已脱敏的错误文本(与回看面回落的图上 `error` 摘要同源),网关只搬运与截断。
// TestStreamFramesToolExecutionEndResultSummary 是端到端锁定:驱动一个真工具回合,
// end 帧必须带工具返回值的已脱敏摘要,start 帧不带。
@@ -39,8 +42,9 @@ func TestStreamFramesToolExecutionEndResultSummary(t *testing.T) {
}
}
// TestFrameResultSummaryShapeMatrix 钉住载荷形状的四个边界(直接经生产映射函数
// streamEventData,不经 hub):在场语义、缺席语义、end-only、上限。
// TestFrameResultSummaryShapeMatrix 钉住载荷形状的各个边界(直接经生产映射函数
// streamEventData,不经 hub):在场语义、缺席语义(各 phase 的空摘要)、
// 失败也带、非 end 帧不带、上限(超长截断 / 恰好等于上限)。
func TestFrameResultSummaryShapeMatrix(t *testing.T) {
t.Parallel()
@@ -55,16 +59,34 @@ func TestFrameResultSummaryShapeMatrix(t *testing.T) {
}
})
t.Run("无结果_键整键缺席", func(t *testing.T) {
t.Run("空摘要_键整键缺席", func(t *testing.T) {
t.Parallel()
// 失败 / 取消 / 超时:loop 侧留空 ⇒ 这里必须让键**缺席**,
// 「真的没有结果」:loop 侧留空 ⇒ 这里必须让键**缺席**,
// 绝不回空串(空串会让安卓端渲染出一个空的结果区)。
// ⚠️ 失败 / 取消 / 超时**不落在这里** —— 那是「有结论」的情形,
// loop 侧给的是已脱敏的错误文本(见下一个子用例)。
for _, phase := range []string{model.ToolPhaseDone, model.ToolPhaseError} {
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: phase,
})
if v, ok := data["resultSummary"]; ok {
t.Fatalf("phase=%s 空摘要仍然出了键 resultSummary=%v;缺席语义要求整键缺席", phase, v)
}
}
})
t.Run("失败_带错误摘要且逐字搬运", func(t *testing.T) {
t.Parallel()
// 失败也是结果:loop 侧把 `error` 文本脱敏后填进同一字段,网关逐字搬运
// (只截断、不二次脱敏),因此失败的 end 帧同样有结果区。
const want = `tool "web_search" failed: upstream 503: upstream 503`
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: model.ToolPhaseError,
Phase: model.ToolPhaseError, ResultSummary: want,
})
if v, ok := data["resultSummary"]; ok {
t.Fatalf("空摘要仍然出了键 resultSummary=%v;缺席语义要求整键缺席", v)
if got, _ := data["resultSummary"].(string); got != want {
t.Fatalf("失败帧 resultSummary = %v, want %q(错误摘要逐字搬运)", data["resultSummary"], want)
}
})
@@ -171,3 +193,108 @@ func TestWSToolExecutionEndResultSummaryOverTheWire(t *testing.T) {
t.Fatalf("线上 tool_execution.end.resultSummary = %v, want %q(工具返回值的渲染)", data["resultSummary"], want)
}
}
// wsFailTool 是注册进真实 dispatcher 的最小**失败**工具:报错文本里故意带一个
// 凭据形状的串,用来在线上同时钉住「失败也带摘要」与「失败路径不放宽脱敏」——
// 摘要是 loop 侧脱敏后的那一份,客户端拿到的东西里不该有凭据原文。
type wsFailTool struct{}
func (wsFailTool) Name() string { return "web_search" }
func (wsFailTool) Description() string { return "test failing tool" }
func (wsFailTool) Parameters() (map[string]any, error) { return map[string]any{}, nil }
func (wsFailTool) Execute(context.Context, any) (any, error) {
return nil, errors.New("upstream 503: token=abcdef1234567890")
}
// wsFailAdapter 是两步脚本:第一步返回一个指向 wsFailTool 的原生工具调用,
// 第二步(错误回灌给模型之后)正常收尾,使回合完整跑完。
type wsFailAdapter struct{ calls int }
func (a *wsFailAdapter) Name() string { return "ws-fail-test" }
func (a *wsFailAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "done"}, nil
}
func (a *wsFailAdapter) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) {
a.calls++
if a.calls == 1 {
return model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_fail_1", Name: "web_search", Args: map[string]any{},
}}}, nil
}
return model.Response{Model: "m", Content: "done"}, nil
}
// TestWSToolExecutionEndErrorSummaryOverTheWire 是失败路径的**线上**锁定(与成功路径
// 那个对称):真 HTTP/WS 服务 + 真 loop + 真失败工具,客户端从连接上读回
// `tool_execution.end` 帧 —— 它必须 phase=error **且**带 resultSummary(失败也是结果),
// 内容是已脱敏的错误文本。这条用例是「实时与回看对同一次失败给出同一份文本」的线上凭据:
// 现场如果摘掉 loop 侧的脱敏,这里会直接读到凭据原文。
func TestWSToolExecutionEndErrorSummaryOverTheWire(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
b := bus.New()
reg := registry.New()
td, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if err := td.Register(wsFailTool{}); err != nil {
t.Fatalf("register tool: %v", err)
}
ag, err := agent.New("You are a helpful assistant.", td, nil, agent.WithModel(&wsFailAdapter{}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
lp := loop.New(loop.Options{
Agent: ag,
MaxRounds: 8,
OnStream: func(ev model.StreamEvent) {
_ = b.Publish(context.Background(), wsStreamSubject, ev)
},
})
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{
Loop: lp, Bus: b, History: newReplayHistoryStore(), Streaming: true,
})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
conn := dialWS(t, ts.URL, tok)
defer conn.Close()
waitConnCount(t, srv, 1)
if err := conn.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"search?"}`)}); err != nil {
t.Fatalf("write chat: %v", err)
}
frames := readChatFramesUntil(t, conn, "tool_execution.end")
var end *wsEvent
for i := range frames {
if frames[i].Type == "tool_execution.end" {
end = &frames[i]
}
}
if end == nil {
t.Fatalf("线上没有收到 tool_execution.end 帧(已见 %v)", chatFrameTypes(frames))
}
data, ok := end.Data.(map[string]any)
if !ok {
t.Fatalf("tool_execution.end 载荷 = %#v,want 对象", end.Data)
}
if got, _ := data["phase"].(string); got != model.ToolPhaseError {
t.Fatalf("线上 tool_execution.end.phase = %v, want error(失败路径)", data["phase"])
}
got, _ := data["resultSummary"].(string)
if got == "" {
t.Fatalf("线上失败帧没带 resultSummary(data=%#v);失败也是结果,与回看面同一份文本",
data)
}
if !strings.Contains(got, "upstream 503") {
t.Fatalf("线上失败帧 resultSummary = %q,want 含工具报错文本 upstream 503", got)
}
if strings.Contains(got, secret) {
t.Fatalf("线上失败帧 resultSummary 里带着凭据原文:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Fatalf("线上失败帧 resultSummary = %q,want 含 [redacted](与出图同一条清洗)", got)
}
}
+289
View File
@@ -0,0 +1,289 @@
package io
import (
"context"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"unicode/utf8"
)
// 本文件实现 design/28 §1 M5(搜索/取正文的降级档)与 §8 B5:
//
// 直连 fetch(既有 SSRF 白名单)→ 失败 / 判定为动态页 ⇒ 浏览器渲染取正文
//
// 并在返回体里**如实标注实际走了哪条**(`fetchedBy`)。挂点是 web-fetch ——
// 「取正文」这件事在本项目里由它承担(web-search 返回的是上游给的摘要/正文片段,
// 它自己不抓页面正文)。
//
// 四条口径:
//
// 1. **默认关**(见 WithBrowserFallback):不注入渲染器就是关。浏览器渲染要起会话
// 甚至起进程,慢且重;M5 是**降级档**不是主路径,绝大多数站点直连足够。
// 2. **绝不静默替换**:走了哪条就写哪条;直连的失败、被跳过的事实、浏览器这一档
// 为什么没走成,全部照实留在返回体或错误里,不许用「看起来成功了」盖掉。
// 3. **不绕 SSRF**:被 SSRF 判定拒绝的目标**不触发**降级 —— 换条路去访问同一个目标
// 是绕过判定,不是降级(design/28 §4-1:判定源只有一个)。
// 4. **浏览器侧自己再判一次**:本包把**原始 URL**原样交给渲染器,不替它免检;
// 渲染器必须用同一份 `ValidateRequestURL` 自己过一遍(见 BodyRenderer 文档)。
// FetchedBy 标注「正文实际从哪条路来」。它是 design/28 §1 M5 的硬要求:
// 必须显式标注来源,不许静默替换。
const (
// FetchedByDirect 表示正文来自直连 fetch(既有 SSRF 白名单那条路)。
FetchedByDirect = "direct"
// FetchedByBrowser 表示正文来自浏览器渲染后的页面正文。
FetchedByBrowser = "browser"
)
const (
// dynamicTextMinChars 是「直连拿到的可见文字够不够,才不像动态页」的下限(按字符计)。
// 骨架页(React/Vue/Angular 的空挂载点)正常只剩导航与页脚,几十个字符量级;
// 200 是「明显不止骨架」的量级,宁可漏判(少起一次浏览器)也不误判。
dynamicTextMinChars = 200
// renderedContentType 是浏览器这一档回报的内容类型。浏览器渲染只对 HTML 页面有意义,
// 而直连失败时我们拿不到原始响应头 ⇒ 如实口径是「渲染出来的正文」,不冒充原始类型。
renderedContentType = "text/html"
// reasonNoBrowserRenderer 是「浏览器降级档没打开」的原因文案(进结果与错误,不美化)。
reasonNoBrowserRenderer = "browser fallback disabled: no body renderer configured"
)
// ErrEmptyRenderedBody 是「浏览器渲染成功但一个字都没取到」。它按**失败**处理:
// 渲染出空正文不是成功,绝不用空文本冒充「取到了」。
var ErrEmptyRenderedBody = errors.New("io: web-fetch: browser render returned empty text")
// RenderedBody 是浏览器渲染一次取回的正文(渲染器只需给这两样)。
type RenderedBody struct {
// Text 是**渲染后**页面的可见正文(已去标签的纯文本)。
Text string
// Source 是渲染来源描述(本机有头 / 已跑的 CDP 端点),如实标注、不含凭据。
Source string
}
// BodyRenderer 是「浏览器渲染取正文」的注入点(design/28 §1 M5 的降级档)。
//
// 实现方必须守住三条:
//
// - **只出站**:只做对外连接,不新开任何监听端口(AGENTS §9;CDP 端口由浏览器自己
// 绑在 127.0.0.1 的随机端口,我们是连接方);
// - **自己过 SSRF**:对拿到的原始 URL 用 plugins/tools/io 的 `ValidateRequestURL`
// 再判一次 —— 那是全仓唯一的判定源(design/28 §4-1),本包不替它免检;
// - **渲染完成后才取正文**:骨架页的 DOM 摘要不是正文(design/28 §6 坑 4),
// 等待条件必须显式。
type BodyRenderer interface {
RenderBody(ctx context.Context, rawURL string) (RenderedBody, error)
}
// fetchBody 是降级链的**唯一入口**:直连 →(失败 / 判定为动态页)⇒ 浏览器渲染。
// 它只决定走哪条,并把两条路的真实结果如实带回(不静默替换、不吞掉任何一条失败)。
func (w *webFetchTool) fetchBody(ctx context.Context, rawURL string, max int64) (bodyFetch, error) {
direct, directErr := w.fetchBodyDirect(ctx, rawURL, max)
if directErr == nil && !direct.dynamicSuspect {
// 直连既成功、又不像动态页 ⇒ 停在原地,**绝不**无谓地起一次浏览器。
return directBodyFetch(direct), nil
}
if errors.Is(directErr, ErrSSRF) {
// 被拒的目标不换路(口径 3)。
return bodyFetch{}, directErr
}
if w.renderer == nil {
// 这一档没打开:如实报原因,并把直连的真实结果/失败原封不动带出去。
return unavailableBrowserFallback(direct, directErr, reasonNoBrowserRenderer)
}
rendered, renderErr := w.renderBody(ctx, rawURL, max)
if renderErr != nil {
return browserFallbackFailed(direct, directErr, renderErr)
}
// 走成了浏览器:把直连那一档的**真实遭遇**一并标注(失败了就写失败原文)。
rendered.directError = errText(directErr)
rendered.dynamicSuspect = direct.dynamicSuspect
return rendered, nil
}
// bodyFetch 是一次取正文的结论。成功与降级失败共用一个形状,使每一条路径的
// 「如实标注」都只有**一个出口**,不会在几条分支里各写各的文案。
type bodyFetch struct {
text string
contentType string
truncated bool
// fetchedBy 是实际来源(FetchedByDirect / FetchedByBrowser)。
fetchedBy string
// directError 是直连那一档的失败原文(直连成功时为空)。
directError string
// browserSource 是浏览器渲染的来源描述(走了浏览器且成功时非空)。
browserSource string
// browserError 是浏览器那一档没能取到正文的原因:没打开、渲染失败、空正文之一。
browserError string
// dynamicSuspect 报告直连拿到的正文是否被判成「像动态页」。
dynamicSuspect bool
}
// directBody 是直连一次取回的原始结果(含「像不像动态页」的判定输入)。
type directBody struct {
text string
contentType string
truncated bool
dynamicSuspect bool
}
// fetchBodyDirect 直连取正文:与既有 web-fetch **完全同一条路** —— 同一份 SSRF 判定
// (validateRequestURL)、同一个客户端、同一个字节上限、同一套 HTML→文本。
func (w *webFetchTool) fetchBodyDirect(ctx context.Context, rawURL string, max int64) (directBody, error) {
if _, err := validateRequestURL(ctx, rawURL, w.allowedHosts); err != nil {
return directBody{}, err
}
if w.client == nil {
return directBody{}, fmt.Errorf("io: web-fetch: no underlying client")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return directBody{}, fmt.Errorf("io: web-fetch: build request: %w", err)
}
resp, err := w.client.Do(req)
if err != nil {
return directBody{}, fmt.Errorf("io: web-fetch: do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return directBody{}, fmt.Errorf("io: web-fetch: url returned status %d", resp.StatusCode)
}
mt := "text/plain"
if rawCT := resp.Header.Get("Content-Type"); rawCT != "" {
parsed, _, perr := mime.ParseMediaType(rawCT)
if perr != nil || !isTextContentType(parsed) {
return directBody{}, fmt.Errorf("io: web-fetch: unexpected content type %q", rawCT)
}
mt = parsed
}
data, truncated, err := readLimited(resp.Body, max)
if err != nil {
return directBody{}, fmt.Errorf("io: web-fetch: read: %w", err)
}
raw := string(data)
text := raw
if isHTML(mt) {
text = htmlToText(raw)
}
return directBody{
text: text,
contentType: mt,
truncated: truncated,
// 判定要在**原始 HTML**上做:转成文本后框架标记就没了。
dynamicSuspect: looksDynamic(mt, text, raw),
}, nil
}
// renderBody 走浏览器这一档,并把渲染结果收成 bodyFetch(内容类型的口径见常量注释)。
func (w *webFetchTool) renderBody(ctx context.Context, rawURL string, max int64) (bodyFetch, error) {
rendered, err := w.renderer.RenderBody(ctx, rawURL)
if err != nil {
return bodyFetch{}, fmt.Errorf("io: web-fetch: browser render: %w", err)
}
if strings.TrimSpace(rendered.Text) == "" {
return bodyFetch{}, ErrEmptyRenderedBody
}
text, truncated := truncateBodyText(rendered.Text, max)
return bodyFetch{
text: text,
contentType: renderedContentType,
truncated: truncated,
fetchedBy: FetchedByBrowser,
browserSource: rendered.Source,
}, nil
}
// unavailableBrowserFallback 处置「这一档压根没打开」:
//
// - 直连也失败 ⇒ **两条都照实报**(错误里既有直连的真实失败,也有这一档为什么没走成);
// - 直连成功但像动态页 ⇒ 仍把直连的正文交出去,但同时如实标注「页面可疑」+
// 「浏览器这一档为什么没走成」—— 不掩盖「本可以用浏览器核一遍」这件事。
func unavailableBrowserFallback(direct directBody, directErr error, reason string) (bodyFetch, error) {
if directErr != nil {
return bodyFetch{}, fmt.Errorf("io: web-fetch: %w; %s", directErr, reason)
}
out := directBodyFetch(direct)
out.browserError = reason
return out, nil
}
// browserFallbackFailed 处置「浏览器这一档走了但没取到正文」:
//
// - 直连也失败 ⇒ 两条失败**都出现在错误里**(谁也别替谁背锅);
// - 直连成功但像动态页 ⇒ 交出直连的正文,同时标注浏览器失败的原因(不掩盖)。
func browserFallbackFailed(direct directBody, directErr, renderErr error) (bodyFetch, error) {
if directErr != nil {
return bodyFetch{}, fmt.Errorf("io: web-fetch: %w; browser fallback failed too: %w", directErr, renderErr)
}
out := directBodyFetch(direct)
out.browserError = renderErr.Error()
return out, nil
}
// directBodyFetch 把直连结果收成 bodyFetch(fetchedBy 恒为 direct)。
func directBodyFetch(d directBody) bodyFetch {
return bodyFetch{
text: d.text,
contentType: d.contentType,
truncated: d.truncated,
fetchedBy: FetchedByDirect,
dynamicSuspect: d.dynamicSuspect,
}
}
// looksDynamic 报告直连拿到的 HTML 页**像不像动态页**:可见正文少于阈值,且原始 HTML
// 里带着前端框架的挂载点 / 水合数据标记 —— 那正是该让浏览器渲染的信号(design/28 §6 坑 4)。
// 非 HTML(纯文本、JSON 等)一律不算动态页:没有任何前端框架参与渲染。
func looksDynamic(mt, text, rawHTML string) bool {
if !isHTML(mt) {
return false
}
if utf8.RuneCountInString(strings.TrimSpace(text)) >= dynamicTextMinChars {
return false
}
return containsAnyFold(rawHTML, dynamicMarkers())
}
// dynamicMarkers 是前端框架「客户端渲染」的常见标记(大小写不敏感匹配)。
// 写成函数而不是包级变量(AGENTS §10:禁包级全局变量)。
func dynamicMarkers() []string {
return []string{
`id="app"`, `id="root"`, `id="__next"`, `id="__nuxt"`,
`__NEXT_DATA__`, `window.__nuxt__`, `data-reactroot`, `ng-version`,
}
}
// containsAnyFold 报告 haystack 是否包含 needles 里的任一项(大小写不敏感)。
func containsAnyFold(haystack string, needles []string) bool {
low := strings.ToLower(haystack)
for _, n := range needles {
if strings.Contains(low, strings.ToLower(n)) {
return true
}
}
return false
}
// truncateBodyText 按**字节**上限截断渲染出来的正文,且不切断一个 UTF-8 字符
// (与直连那条档同一个口径:max 是字节预算;max ≤ 0 视为无上限)。
func truncateBodyText(s string, max int64) (string, bool) {
if max <= 0 || int64(len(s)) <= max {
return s, false
}
cut := s[:max]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut, true
}
// errText 把 error 收成「原样文本 / 空串」(空 error 收成空串,便于直接进结果体)。
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
+311
View File
@@ -0,0 +1,311 @@
package io
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeBodyRenderer 是 BodyRenderer 的测试替身:它**记录**自己被调了几次、拿到的
// URL 是什么 —— 「直连成功时绝不碰浏览器」「被拒的目标不换路」这类要求只能靠
// calls 计数钉住,光看返回体看不出来。
type fakeBodyRenderer struct {
body RenderedBody
err error
calls int
urls []string
}
func (f *fakeBodyRenderer) RenderBody(_ context.Context, rawURL string) (RenderedBody, error) {
f.calls++
f.urls = append(f.urls, rawURL)
if f.err != nil {
return RenderedBody{}, f.err
}
return f.body, nil
}
// serveHTML 起一个恒返回这段 HTML 的测试服务(Content-Type 为 text/html)。
func serveHTML(t *testing.T, doc string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, doc)
}))
t.Cleanup(srv.Close)
return srv
}
// serveStatus 起一个恒返回指定状态码的测试服务(用于造「直连失败」)。
func serveStatus(t *testing.T, code int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", code)
}))
t.Cleanup(srv.Close)
return srv
}
// normalDoc 是一篇「正文够长、不像动态页」的普通页面(> dynamicTextMinChars 字)。
func normalDoc() string {
return `<html><body><h1>Hello</h1><p>` + strings.Repeat("正文句子。", 60) + `</p></body></html>`
}
// skeletonDoc 是一个只有前端框架挂载点、没有任何可见正文的**骨架页**。
func skeletonDoc() string {
return `<html><head><script>window.__NUXT__={}</script></head>` +
`<body><div id="app"></div><script src="/x.js"></script></body></html>`
}
// rendererTool 造一把带渲染器的 web-fetch(可选注入),并把服务主机放进白名单。
func rendererTool(t *testing.T, srv *httptest.Server, r BodyRenderer) *webFetchTool {
t.Helper()
opts := []Option{WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client())}
if r != nil {
opts = append(opts, WithBrowserFallback(r))
}
return webFetchToolForTest(t, opts...)
}
// ① 直连成功 + 不像动态页 ⇒ **绝不**走浏览器(calls 必为 0),且标注 direct。
func TestFetchBodyChain_DirectSuccessNeverTouchesBrowser(t *testing.T) {
t.Parallel()
srv := serveHTML(t, normalDoc())
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByDirect)
}
if r.calls != 0 {
t.Errorf("直连成功却起了浏览器:calls = %d, urls = %v", r.calls, r.urls)
}
if !strings.Contains(m["text"].(string), "Hello") {
t.Errorf("text 不是直连的正文: %q", m["text"])
}
// 没有发生过的事,不许出现在结果里(空字段一律不写)。
for _, k := range []string{"directError", "browserSource", "browserError", "dynamicSuspect"} {
if _, exists := m[k]; exists {
t.Errorf("结果里不该有 %q(本次没有发生)", k)
}
}
}
// ② 直连失败 + 浏览器可用 ⇒ 走浏览器、标注 browser,并把直连的失败照实留痕。
func TestFetchBodyChain_BrowserRendersAfterDirectFailure(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{body: RenderedBody{Text: "浏览器渲染出来的正文", Source: "本机有头 Chrome"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByBrowser {
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
}
if m["text"] != "浏览器渲染出来的正文" {
t.Errorf("text = %q", m["text"])
}
if m["browserSource"] != "本机有头 Chrome" {
t.Errorf("browserSource = %v(来源必须如实带出)", m["browserSource"])
}
if de, _ := m["directError"].(string); !strings.Contains(de, "status 500") {
t.Errorf("directError = %q,必须留下直连为什么没成", m["directError"])
}
// 渲染器拿到的必须是**原始 URL**(它自己要再过一遍 SSRF 判定)。
if r.calls != 1 || len(r.urls) != 1 || r.urls[0] != srv.URL {
t.Errorf("渲染器入参错: calls = %d, urls = %v, want [%s]", r.calls, r.urls, srv.URL)
}
}
// ③ 浏览器不可用(这一档没打开)+ 直连失败 ⇒ 两条都照实报,绝不只报一条、绝不冒充成功。
func TestFetchBodyChain_BrowserUnavailableReportsBothFailures(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 默认关
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err == nil {
t.Fatal("两条档都没成,必须有错误,不许冒充成功")
}
if !strings.Contains(err.Error(), "status 500") {
t.Errorf("错误里丢了直连的真实失败: %v", err)
}
if !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
t.Errorf("错误里没说清浏览器这一档为什么没走: %v", err)
}
}
// ④ 浏览器这一档走了但失败 + 直连也失败 ⇒ 两条失败都出现在错误里(谁也别替谁背锅)。
func TestFetchBodyChain_BrowserRenderFailureKeepsBothFailures(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{err: errors.New("CDP 连不上")}
wf := rendererTool(t, srv, r)
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err == nil {
t.Fatal("两条档都没成,必须有错误")
}
for _, want := range []string{"status 500", "CDP 连不上"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里缺 %q: %v", want, err)
}
}
}
// ⑤ 直连成功但**像动态页** + 浏览器关着 ⇒ 交出直连正文,但把「页面可疑 + 浏览器
// 这一档为什么没走」如实标出来(不掩盖「本可以核一遍」)。
func TestFetchBodyChain_DynamicPageWithoutRendererIsHonest(t *testing.T) {
t.Parallel()
srv := serveHTML(t, skeletonDoc())
wf := rendererTool(t, srv, nil) // 不注入渲染器 = 这一档关着
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q(浏览器没走成,来源仍是直连)", m["fetchedBy"], FetchedByDirect)
}
if m["dynamicSuspect"] != true {
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
}
if m["browserError"] != reasonNoBrowserRenderer {
t.Errorf("browserError = %v,必须说明这一档为什么没走", m["browserError"])
}
}
// ⑥ 直连成功但像动态页 + 浏览器可用 ⇒ 改用浏览器渲染,并同时标注「页面可疑」。
func TestFetchBodyChain_DynamicPageUsesBrowser(t *testing.T) {
t.Parallel()
srv := serveHTML(t, skeletonDoc())
r := &fakeBodyRenderer{body: RenderedBody{Text: "渲染后的正文", Source: "本机有头 Chrome"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if m["fetchedBy"] != FetchedByBrowser {
t.Errorf("fetchedBy = %v, want %q", m["fetchedBy"], FetchedByBrowser)
}
if m["text"] != "渲染后的正文" {
t.Errorf("text = %q", m["text"])
}
if m["dynamicSuspect"] != true {
t.Errorf("dynamicSuspect = %v,判定为动态页这件事必须留痕", m["dynamicSuspect"])
}
if _, exists := m["directError"]; exists {
t.Errorf("直连其实成功了,不该有 directError: %v", m["directError"])
}
if r.calls != 1 {
t.Errorf("渲染器调用次数 = %d, want 1", r.calls)
}
}
// ⑦ 被 SSRF 判定拒绝的目标**不换路**:降级档绝不能变成绕过判定的通道。
func TestFetchBodyChain_SSRFDeniedNeverFallsBackToBrowser(t *testing.T) {
t.Parallel()
r := &fakeBodyRenderer{body: RenderedBody{Text: "不该被用到", Source: "fake"}}
wf := webFetchToolForTest(t, WithBrowserFallback(r))
_, err := wf.Execute(context.Background(), map[string]any{"url": "http://localhost:9/"})
if !errors.Is(err, ErrSSRF) {
t.Fatalf("期望 ErrSSRF,实得 %v", err)
}
if r.calls != 0 {
t.Errorf("被拒的目标居然起了浏览器(calls = %d)—— 那是绕过判定,不是降级", r.calls)
}
}
// ⑧ 渲染成功但一个字都没取到 ⇒ 按失败处理(空正文不许冒充「取到了」)。
func TestFetchBodyChain_EmptyRenderedBodyIsFailure(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{body: RenderedBody{Text: " \n "}}
wf := rendererTool(t, srv, r)
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if !errors.Is(err, ErrEmptyRenderedBody) {
t.Fatalf("期望 ErrEmptyRenderedBody,实得 %v", err)
}
if !strings.Contains(err.Error(), "status 500") {
t.Errorf("错误里还得有直连的真实失败: %v", err)
}
}
// ⑨ 渲染出来的正文同样受字节上限约束,且截断不切断 UTF-8 字符。
func TestFetchBodyChain_RenderedTextRespectsMaxBytes(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
r := &fakeBodyRenderer{body: RenderedBody{Text: strings.Repeat("正", 50), Source: "fake"}}
wf := rendererTool(t, srv, r)
got, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL, "max_bytes": 10})
if err != nil {
t.Fatalf("Execute: %v", err)
}
m := got.(map[string]any)
if len(m["text"].(string)) != 9 {
t.Errorf("按 10 字节截断后应为 3 个汉字 = 9 字节,实得 %d", len(m["text"].(string)))
}
if m["truncated"] != true {
t.Errorf("truncated = %v, want true", m["truncated"])
}
}
// ⑩ 开关默认关:不传选项、或显式传 nil,渲染器都是 nil(= 这一档关着)。
func TestWithBrowserFallback_DefaultsAndNilAreOff(t *testing.T) {
t.Parallel()
srv := serveStatus(t, http.StatusInternalServerError)
if wf := webFetchToolForTest(t); wf.renderer != nil {
t.Error("默认(不传选项)必须是关:renderer 应为 nil")
}
wf := webFetchToolForTest(t, WithAllowedHosts(serverHost(srv)), WithHTTPClient(srv.Client()),
WithBrowserFallback(nil))
if wf.renderer != nil {
t.Fatal("WithBrowserFallback(nil) 必须是关")
}
_, err := wf.Execute(context.Background(), map[string]any{"url": srv.URL})
if err == nil || !strings.Contains(err.Error(), reasonNoBrowserRenderer) {
t.Fatalf("关着的时候直连失败必须如实报「这一档没打开」: %v", err)
}
}
// ⑪ 动态页判定的三条边界:非 HTML 不算、正文够长不算、没有框架标记不算。
func TestLooksDynamic(t *testing.T) {
t.Parallel()
cases := []struct {
name string
mt string
text string
rawHTML string
want bool
}{
{"骨架页 + 框架标记", "text/html", "", skeletonDoc(), true},
{"骨架页但没有任何框架标记", "text/html", "", `<html><body></body></html>`, false},
{"正文够长", "text/html", strings.Repeat("字", dynamicTextMinChars), skeletonDoc(), false},
{"非 HTML(纯文本)", "text/plain", "", `id="app"`, false},
{"非 HTML(JSON)", "application/json", "", `{"mount":"id=\"app\"","x":"id=\"root\""}`, false},
}
for _, tc := range cases {
if got := looksDynamic(tc.mt, tc.text, tc.rawHTML); got != tc.want {
t.Errorf("%s: looksDynamic = %v, want %v", tc.name, got, tc.want)
}
}
}
+6 -1
View File
@@ -33,7 +33,12 @@
// configuration (the model only supplies the query), hence their hosts are trusted
// by this tool alone: a loopback fallback endpoint stays reachable without widening
// the shared http/web-fetch allowlist. API keys never reach logs, results or prompts.
// - web-fetch: size- and content-type-limited readable text extraction.
// - web-fetch: size- and content-type-limited readable text extraction. 取正文走
// **降级链**(design/28 §1 M5 / §8 B5):直连 fetch →(失败 / 判定为动态页)
// ⇒ 浏览器渲染取正文,返回体用 `fetchedBy`(direct / browser)**如实标注实际
// 走了哪条**,两条档的失败原文分别留在 directError / browserError 里 —— 绝不静默
// 替换、绝不把失败说成成功。这一档**默认关**(WithBrowserFallback);被 SSRF
// 判定拒绝的目标**不换路**(换条路访问同一目标是绕过判定,不是降级)。
// - knowledge: RAG lookup over the runtime memory retriever; a clear message
// when no knowledge base is configured.
//
+22
View File
@@ -67,6 +67,10 @@ type tools struct {
// searchMaxResults 是 web-search 自己的条数上限(0 = 沿用 maxResults)。
// 与 WithMaxResults 分开,是因为后者会一起改到 knowledge 工具的条数。
searchMaxResults int
// bodyRenderer 是「浏览器渲染取正文」这一档(design/28 §1 M5 / §8 B5)。
// nil = **这一档关着**(默认),见 WithBrowserFallback。
bodyRenderer BodyRenderer
}
// SearchProviderSpec 是组合根给 web-search 的**一家** provider 的接线(一家一行)。
@@ -177,6 +181,24 @@ func WithSearchMaxResults(n int) Option {
}
}
// WithBrowserFallback 打开 design/28 §1 M5 / §8 B5 的降级档:web-fetch **直连取正文**
// 失败、或直连拿到的页面被判成动态页时,改由**浏览器渲染**取正文,并在返回体里如实
// 标注实际走了哪条(`fetchedBy` = direct / browser)。
//
// **默认关**:不传这个选项就是关,传 nil 也是关。两条理由 ——
//
// 1. M5 是**降级档**不是主路径:浏览器渲染要起会话(必要时起进程),比直连慢一个量级;
// 绝大多数站点直连足够。它是「直连不行时的补一手」,不该挂在默认路径上。
// 2. 浏览器是否可用取决于宿主(本机有没有 Chrome/Chromium、有没有配 CDP 端点),
// 拿不到就不该把一条注定走不通的档挂上去 —— 与 web-search「拿不到后端就不注册」
// 同一个诚实口径。
//
// 传入的 renderer 必须自己满足 BodyRenderer 的红线(只出站、复用同一份 SSRF 判定源、
// 渲染完成后才取正文)。这一档无论开关,**都不会**把被 SSRF 拒绝的目标换条路再去访问。
func WithBrowserFallback(r BodyRenderer) Option {
return func(t *tools) { t.bodyRenderer = r }
}
// WithKnowledgeRetriever wires the knowledge tool to a runtime memory retriever.
// WithKnowledgeRetriever(nil) makes knowledge report "no knowledge base".
func WithKnowledgeRetriever(r KnowledgeRetriever) Option {
+32 -38
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"mime"
"net/http"
"strings"
@@ -21,6 +20,9 @@ type webFetchTool struct {
allowedHosts []string
maxBytes int64
log *slog.Logger
// renderer 是「浏览器渲染取正文」这一档(design/28 §1 M5 / §8 B5)。
// nil = 关着(默认,见 WithBrowserFallback)。
renderer BodyRenderer
}
func newWebFetchTool(t *tools) *webFetchTool {
@@ -29,6 +31,7 @@ func newWebFetchTool(t *tools) *webFetchTool {
allowedHosts: t.allowedHosts,
maxBytes: t.maxFetchBytes,
log: t.log,
renderer: t.bodyRenderer,
}
}
@@ -53,6 +56,10 @@ func (w *webFetchTool) Parameters() (map[string]any, error) {
}
// Execute implements sdk.Tool.
//
// 取正文走**降级链**(design/28 §1 M5 / §8 B5):直连 fetch →(失败 / 判定为动态页)
// ⇒ 浏览器渲染。结果里的 `fetchedBy` 是**如实标注**(direct / browser);两条档的
// 失败原文各自留在 directError / browserError 里,绝不静默替换、绝不掩盖。
func (w *webFetchTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
@@ -62,48 +69,35 @@ func (w *webFetchTool) Execute(ctx context.Context, args any) (any, error) {
if strings.TrimSpace(rawURL) == "" {
return nil, fmt.Errorf("io: web-fetch: url is required")
}
if _, err := validateRequestURL(ctx, rawURL, w.allowedHosts); err != nil {
return nil, err
}
if w.client == nil {
return nil, fmt.Errorf("io: web-fetch: no underlying client")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("io: web-fetch: build request: %w", err)
}
resp, err := w.client.Do(req)
if err != nil {
return nil, fmt.Errorf("io: web-fetch: do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("io: web-fetch: url returned status %d", resp.StatusCode)
}
mt := "text/plain"
if rawCT := resp.Header.Get("Content-Type"); rawCT != "" {
var err error
mt, _, err = mime.ParseMediaType(rawCT)
if err != nil || !isTextContentType(mt) {
return nil, fmt.Errorf("io: web-fetch: unexpected content type %q", rawCT)
}
}
max := w.maxBytes
if v, ok := argInt(m, "max_bytes"); ok && v > 0 {
max = int64(v)
}
data, truncated, err := readLimited(resp.Body, max)
body, err := w.fetchBody(ctx, rawURL, max)
if err != nil {
return nil, fmt.Errorf("io: web-fetch: read: %w", err)
return nil, err
}
text := string(data)
if isHTML(mt) {
text = htmlToText(text)
}
return map[string]any{
out := map[string]any{
"url": rawURL,
"content_type": mt,
"text": text,
"truncated": truncated,
}, nil
"content_type": body.contentType,
"text": body.text,
"truncated": body.truncated,
// fetchedBy:正文**实际**来自哪条路。它是 M5 的硬要求(不许静默替换)——
// 见到 browser 就意味着直连失败过、或被判成了动态页。
"fetchedBy": body.fetchedBy,
}
// 以下字段只在**确有其事**时出现(有内容的失败/来源才写,不塞空串)。
if body.directError != "" {
out["directError"] = body.directError
}
if body.browserSource != "" {
out["browserSource"] = body.browserSource
}
if body.browserError != "" {
out["browserError"] = body.browserError
}
if body.dynamicSuspect {
out["dynamicSuspect"] = true
}
return out, nil
}