# 架构与实现 > 改 `src/` 之前读这份。内容是**当前代码的既定行为与不变式**,不是历史。 > CDP 层面的逐条实测结论在 `CDP实测事实.md`。 --- ## 1. 仓库定位与依赖约束 **为什么做成独立仓**:官方 `CONTRIBUTING.md` 明写「现阶段不收外部 PR」,GitHub API 显示当前账号对 `deepseek-ai/deepseek-harness` 的权限是 `push: false`,且官方鼓励社区插件以独立仓 + `dsh-plugin` topic 发布。 往上游 `packages/` 里加代码只会沉淀成永远无法提交的本地工作区 —— 而 `cordis.patch.yml` 与 preset 恰恰是 上游每次 release 都会改的文件,下轮拉取必冲突。 **依赖一律走 `link:` 指向本地 harness 检出**,不要改成 npm 版本号: ```json "@deepseek-ai/cordis": "link:../deepseek-harness/vendor/cordis", "@deepseek-ai/dsh-tools": "link:../deepseek-harness/packages/core/tools", "@deepseek-ai/schemastery": "link:../deepseek-harness/vendor/schemastery" ``` 两条实测理由:① npm 上的版本落后 5 个 minor(`0.0.1-rc.1` vs `0.1.5-rc.2`),类型与接口都对不上; ② npm 上的 dsh 生态**不完整**,直接 `pnpm install` 会因 `@deepseek-ai/dsh-type-meta` 404 而失败(该包根本没发布)。 > `@deepseek-ai/dsh-subprocess` 仍在 `devDependencies` 里但**代码从没 import 过**:两个 provider 都不经过 dsh 的 > subprocess 服务,「进程树回收」这条语义在本仓没有落地对象。 **peerDependencies 的版本判据**:现为 `^0.1.6-alpha.1`。按 semver prerelease 规则(同 `[major,minor,patch]` 且同样带 prerelease 才匹配),`0.1.6-alpha.2` 满足该范围;上游一旦升到 `0.1.7` / `0.2.x` 就既不匹配也挡不住, **届时必须跟着 bump**。本地 `link:` 能跑不代表 peer 范围正确。 --- ## 2. 接线:五行 patch,全在 host 平面 `cordis.patch.yml` 的 `insert` 有五行: | 行 | 说明 | |---|---| | `browser` | `ctx.browser` 能力服务,跨会话共享,不能按 preset 分叉 | | `browser-cdp` | provider(外部 Chrome) | | `browser-electron` | provider(桌面端自己的 Electron 窗口),默认不参与选择 | | `tool-browser` | 模型可见的工具 | | `dsh-webops-plugin`(**裸包名**) | host 半边是空实现,只为让浏览器那半边被客户端模块表发现 | **为什么客户端半边必须挂在裸包名行上**:dsh 的 `locatePkgJson()` 先调 `exactPackageSpecifier(name)`, 该函数对**非 scoped 且带 `/`** 的 specifier 直接返回 `undefined`,于是整行被判为「永久不是客户端行」, 永远不会去读 `dsh.client` 与 `exports["./client"]`。**带子路径的 host 行不可能带上客户端半边。** **host 平面为什么对四种 surface 都成立**:agent 的 `tools` 视图按 scope 链解析 —— 未加入 preset 的 agent 解析到 「空的 global 层」,已加入 preset 的 agent 同时看到 global 层与 preset 层。host 平面的行在所有 surface (TUI / headless / web / 桌面端)都生效,**除非该 surface 的 overlay 显式 `disabled: true`**。 本插件的三行不在任何 overlay 的禁用名单里,所以四种 surface 都可见。 > 已在真实 profile 上闭环验证:会话日志 `request/header.tools` 里四个 `webpage_*` 工具在列, > `system/message` 里本插件的分段文本完整在列。 > > 若将来上游把工具行整体搬进 preset 并同时禁用 host 平面的一切 `tool-*`, > 本节结论失效 —— 届时退路是把该行搬进 `$DSH_HOME/.agent-presets//agent.cordis.yml`。 **三条一次性环境约束**(写代码时别违反): 1. **插件入口模块不能有 `export default`**。loader 是 `exports = exports.default ?? exports`, 有默认导出就会替换整个模块命名空间,`inject` 消失,症状是启动时报 `cannot get property "systemPrompt" without inject`。**`tsc` 与单测都发现不了**,只有真起一次才暴露。 例外:`src/browser/index.ts` 的默认导出就是 `BrowserRuntime` 本身,本来就该是默认导出。 2. **`SECTION_ORDERS` 是中央封闭注册表**,外部插件加不了键,只能给 `section({ order })` 传显式数字。本插件用 `2050`。 3. **patch 行不带 `config:` 时 `apply` 收到的是 `undefined`,不是 schemastery 的默认值**。 所以必须自己 `config: Config = {}` 且每一格都用 `??` 兜底。 另外 **patch 命中某一行时是整块替换 config(非深合并)**,覆盖时要重述该行的所有 config 键。 --- ## 3. 两个 provider **先说为什么自己写 CDP 而不是上 Playwright**:`src/browser-cdp/protocol.ts` 只做两件事 —— DevTools 的 `/json/{version,list,new,close}` HTTP 端点,以及 WebSocket 上的 `{id, method, params}` 命令通道 (约 400 行,含超时、取消、断线结算)。换来的是**零浏览器下载、零 `allowBuilds` 授权、零 Chromium 体积, 以及用户自己的登录态** —— 而这四点恰好是这个插件的立身之本(它操作的是用户日常那个浏览器)。 `snapshot.ts` 走**可访问性树**而不是视觉树同理:浏览器已经算好了角色与名称, 比从布局反推少一个数量级的代码,对模型也更友好。 | provider | id | 开的是什么 | 谁用 | |---|---|---|---| | `browser-cdp` | `cdp` | 外部 Chrome 的标签页 | CLI / 想接自己日常浏览器时 | | `browser-electron` | `electron` | 真正的 `BrowserWindow` | 桌面端(默认) | `browser-cdp` 连的是**外部 Chrome** 的调试端口。桌面端里这条路是死的:桌面端自己的 `--remote-debugging-port` 就是它自己的渲染进程;内置 Chromium **不实现 `PUT /json/new`** (实测只回 `Could not create new page`);而桌面端 host 又跑在**纯 Node** 子进程里,拿不到 `BrowserWindow`。 所以 `browser-electron` 的做法是:host 进程 **spawn 一个 Electron 窗口宿主**(`src/browser-electron/host.cjs`), 窗口由它创建,插件继续用同一套 CDP 命令驱动。`CdpBrowserProvider` 原样复用,**只换传输层**(`ElectronWindowTransport`)。 **「启用」不等于「选中」**:`browser-electron` 的 `available()` 多一道 `enabled` 闸门,否则 `cdp`(端点活着)与 `electron`(二进制找得到)会同时「可用」而触发 `BROWSER_PROVIDER_AMBIGUOUS`。 而 `browser-cdp` 的 `available()` 是**乐观**的(探测拿不准就返回 `true`,把精确诊断留给 `open()`), 所以**「同时可用」在桌面端是常态**。指定方式:`DSH_BROWSER_PROVIDER`(env)—— `config.provider` 写不进(桌面端 profile 每次重建)。打包态由 `main.ts` 顶层**判空兜底** `= 'electron'`。 ### 桌面端的形态要求(与开发态是两套) `validateDesktopPluginGraph` 对 profile 里的插件做四类断言,**每一条都与开发期的 `link:` 路线冲突**: | 断言 | 对本仓的影响 | |---|---| | `linked private package` | profile 的 `node_modules` 下出现 symlink 即拒 → `dsh plugin add` 产生的正是 symlink | | `package resolves outside profile` | 依赖闭包必须物理位于 profile 目录内 → 必须 vendor 一份副本 | | `must declare as a peer dependency` | dsh 的共享包只能出现在 `peerDependencies`,出现在 `dependencies` 直接报错 | | `requires @, found ` | peer 版本必须满足范围 | → 桌面端要的是**真实文件副本 + `peerDependencies` 声明 + 版本对齐**。CLI 的 `link:` 路线桌面端不吃。 --- ## 4. 窗口宿主(`host.cjs`) ![桌面端 host 进程开出的 Electron 窗口](window-electron.png) ### 三个必须踩准的时机(全是「命令发出去永远不回」) 1. **通道用 TCP,不要用 stdio。** Electron(Windows)主进程的 `process.stdin` 会**立刻 EOF**, `on('end')` 一收尾就把 app 关了 —— 症状是「窗口刚建好就自己没了」。 stdout 是通的,所以反向:宿主监听 `127.0.0.1:0`,把端口从 stdout 宣布。 2. **`debugger.attach()` 要等 `dom-ready`。** 窗口刚 `new` 出来就 attach,`Page.enable` 直接挂住。 3. **建窗口后必须显式 `loadURL()`。** 哪怕加载 `about:blank`:不加载就没有导航,`dom-ready` 永远不来。 另外 `CdpSocket` 的 `open` 事件必须是「下一个微任务」派发:`CdpConnection` 的构造是同步的,派发早了监听器还没挂上。 ### 开发态 vs 便携版(app 模式) | | 开发态(script 模式) | 便携版(app 模式) | |---|---|---| | 起的是什么进程 | `electron.exe host.cjs` | `DeepSeek Harness.exe --user-data-dir=` | | 脚本路径怎么传 | argv[1] | 环境变量 `DSH_BROWSER_ELECTRON_HOST` | | Electron 二进制从哪来 | `DSH_BROWSER_ELECTRON_PATH` | `DSH_APP_EXECUTABLE`(= `process.execPath`) | | 谁接管这个进程 | Electron 默认行为 | `main.ts` 的早期分支(harness 补丁) | 便携版没有独立的 `electron.exe`(`app/resources/` 只有 `app.asar` / `dsh` / `runtime`), 而「`打包exe host.cjs`」不成立 —— 打包应用的 app 路径固定在 asar 里,argv 里的脚本路径会被忽略。 所以走 app 模式。两个必须踩准的点: 1. **早期分支要在 `claimDesktopSingleInstance()` 之前** —— 否则第二个实例会被 `requestSingleInstanceLock()` 直接 `quit()`。 2. **必须给第二个实例独立的 `--user-data-dir`** —— 否则它和主应用抢同一份 userData。 代价(已知取舍):那个浏览器窗口的登录态**不持久、也不与主应用共享**。 `host.cjs` 是 CJS,在 asar 之外,用 `createRequire(import.meta.url)(script)` 加载绝对路径即可(Electron 支持)。 窗口标题恒为 **`dsh网页窗口`**(宿主是 `BaseWindow`,标题只认创建时那一个值,不跟页面标题走)。 ### 窗口「整片变白」的守卫 **根因**:`layout()` 无条件采信 `shell.getContentBounds()`。窗口最小化/隐藏期间任何一次 `layout()` 都会把每个视图 `setBounds` 成 0 宽(此时返回值是 `{x:-16000,y:-16000,width:0,height:0}`), 而恢复窗口**不触发 `resize`**,于是窗口一直只有 `backgroundColor`。 **修法两半,各自都能独立避免症状**: - **不制造伤害**:`layout()` 开头 `if (shell.isMinimized() || !shell.isVisible() || bounds.width <= 0 || bounds.height <= 0) return` - **出事能自愈**:`shell.on('restore', layout)` + `shell.on('show', layout)` **两个触发入口,同一个根因**:① 模型发 `webpage_tabs(action=activate)`;② **`openTab()` 末尾那次无条件 `layout()`** —— 页面弹窗转标签(`target=_blank` / `window.open` → `setWindowOpenHandler` → `openTab`)走这里, 会把**已经在渲染的旧标签一起**钉成 0 宽。这条最贴近真实报告(「点了某条之后才空白」)。 > 这条只能做**源码级断言**(`verify:portable` 断言守卫与两个钩子在出货产物里)。 > 端到端的「最小化 → 恢复后仍渲染」走不了自动化:CDP 的 `Browser` 域在页面级会话里不可用 > (`Browser.getWindowForTarget` → `wasn't found`),宿主也没有暴露窗口操作 op。 ### 弹窗标签的通报 `openedTabs` 整条弯路的**起点**是模型对弹窗标签一无所知。修法:`BrowserMutationResult` 增 `openedTabs` (provider 侧按**会话台账差集**算,`mutate()` / `collectOpenedTabs()`),工具层投影成 `opened_tabs` 并在回执正文点名。 - **不用额外等待**:通报延迟实测 min 140 / 中位 152 / max 156ms,而不导航的点击本来就要跑满 `MUTATION_NAVIGATION_POLL_MS = 800` 的导航轮询 —— 差集在那之后读,已经就绪。 - 只有「点击既导航又弹窗」(两者并发,导航首检即命中导致轮询提前结束)才会用到 `TAB_OPEN_WATCH_MS = 250` 的补观测窗口,且**过了截止点就不等**,慢路径零代价。 - **必须延迟式地测**:单测里 `FakeChrome.popupDelayMs` 默认 120ms;若让假 transport 同步收编, 800ms 轮询与补窗口都成了摆设,测试会**假绿**。反向验证:把 `TAB_OPEN_WATCH_MS` 改成 0, 「grace window」那条必须转红(另两条应当仍绿)。 ### 调试器 attach / DevTools **DevTools 与 agent 的 debugger 不是互斥的**。真实约束只是 `openDevTools` 的**调用时机**: 调试器还 attach 着时它会**静默失败**(不抛错,`isDevToolsOpened()` 保持 false); DevTools 打开之后这个 target 就不再排斥第二个客户端,可以在 `devtools-opened` 回调里**同步**接回(连延迟都不需要)。 - 人工打断点**卡不住** agent 的命令。 - 接线形态:`detach()` → `openDevTools({mode:'undocked'})` → `wc.once('devtools-opened', () => { attachDebugger(); reenableDomains() })`。 - **`Inspector.detached` 不等于会话失效**:它是毫秒级过渡,紧跟其后就是接回。 **收到它不得推进 ref 纪元**,否则每次人工看一眼 DevTools 都会把模型的 ref 全废掉。 判「真的没了」看 reason:`'target closed'` 才是真断(但主动 detach 也是这个 reason,区分要靠自己的状态位)。 - **re-attach 不保证之前 enable 过的 domain 还在**,采集器必须在接回后重新 enable。 ### 截图与超时 **`Page.captureScreenshot` 在 `show:false` 的窗口上会永久挂起**(不返回也不报错)。凡走截图的路径,窗口必须可见。 两条链路的超时是分开的,别以为某处写了 `timeoutMs` 就万事大吉: | 链路 | 超时在哪 | |---|---| | `browser-cdp` 直连端点 | `CdpConnection.send(..., {timeoutMs})` 默认 30s,provider 内部收紧到 5s | | `browser-electron` 窗口宿主 | `bridge.request` 有 `commandTimeoutMs`(**在父进程侧**,超时报 `BRIDGE_TIMEOUT`) | | **`host.cjs` 的 `debugger.sendCommand`** | ❌ **裸的** —— 父进程超时后 pending 被删、命令以 `BRIDGE_TIMEOUT` 结束,但**宿主进程里那条 `await` 会永远悬着** | --- ## 5. ref 纪元与 `RefRegistry`(核心状态机) ### 两条基本规则 1. **序号在会话内单调递增,绝不重置。** 新快照从上一个纪元的最大值之后继续编号 → 「同号不同元素」不可能出现。 2. **解析前先比纪元。** 持有 ref 表的永远只是「当前纪元」那一份;导航与下一次全页快照都把整张表换掉。 于是旧 ref 只会落到「表里没有」,报 `BROWSER_STALE_REF`(观察过页面)或 `BROWSER_SNAPSHOT_REQUIRED`(从未观察过)。 两者都是模型该用「重新观察」恢复的错误。`refs.resolve()` 的检查发生在**任何 CDP 页面命令发出之前**(不会先点再查)。 ### 写前门:动作发出前的最后一道校验 `resolveObjectId`(`provider.ts:1384`)拿到 `objectId` 之后、派发任何输入事件之前, `assertPreActionGate`(`provider.ts:1424`)用**一次** `Runtime.callFunctionOn` 同时查两件事: - **粗门**:**顶层文档**地址(`window.top.location.href`)与纪元发布时刻记下的 url 不一致 → `BROWSER_STALE_REF`, 并 `invalidate()` + `noteDocumentChange()`(与事后的 `detectNavigation` 保持同一套动作)。 堵的是「快照之后人工换了路由,旧 ref 静默点到新页面上的同类元素」—— 同文档 SPA 路由不会重编 `backendNodeId`,所以这是既有各道门都覆盖不到的一格。 - **细门**:`this.isConnected === false` → `BROWSER_STALE_REF`,但**不**作废纪元、**不**通报(地址没变,页面上其余 ref 仍可用)。 四条口径,接手时别改回去: 1. **读顶层而不是 `location.href`**:`objectId` 属于元素**自己那个**文档,纪元里存的是顶层地址, 拿子 frame 的 href 去比会把**同文档的 iframe 元素**一律判成「地址变了」。跨源 frame 读顶层抛 `SecurityError`(按规范推断,**未实测**)→ 脚本 `try/catch` 成 `null` → 当成没证据。 **那个 `try` 不只是多覆盖一档**:同一次往返还要带回 `connected`,不 `try` 就连细门一起在跨源 frame 里静默掉(用例 `provider.test.ts:1041` 钉住,摘掉 `try` 即红)。 换来的失明也要记一笔:**同源子 frame 自己 `pushState`** 时顶层地址不动、`backendNodeId` 不重编 → 两道门全绿。 2. **fail-open**:命令失败、读回来是 `null` / 空串 / 非字符串,一律放行 —— 门没证据不该把一次正常 操作变成失败。写侧两个写入点各自归一(`epochUrl` 只有「有证据 / 没证据」两态):`publish` (`refs.ts:195`)与 `restore`(`refs.ts:242`)都把空串归一成 `undefined`,否则空串会既比不出东西、 又挡住回填。与它相对的 `revalidate` 是 fail-closed,两者别混。 3. **`wait` 的 hidden 分支只豁免细门**(`resolveObjectId(..., { allowDetached: true })`,`provider.ts:1787`): 「元素已脱离文档」正是那条等待的**成功条件**。粗门对它照查。 4. **被粗门拦下的纪元不会因此永久关掉粗门**:模型 `webpage_revalidate` 时,`revalidate` 会给 没有地址的纪元补一份当下读到的地址(`provider.ts:560`-`:564`)。两条硬口径: ① **来源必须与门同源** —— 走 renderer 的 `readPageMeta`,**不能**顺带取 `Page.getFrameTree` 里主 frame 的 url(那份是浏览器进程镜像,导航在飞 / 重定向 / 特权页上比 renderer 慢一档, 拿它当基线会误伤并作废整个纪元;第 3 批为此改错过一次);② **`pending` 为空也要补** —— 那次 `publish` 根本没读到地址的纪元只能靠这里续命。代价:只在缺依据时多一次 `Runtime.evaluate`。 `readMainLoaderId`(`provider.ts:1237`)因此只读 `loaderId`;`publish` 侧仍是两次相邻的读 (`readPageMeta` + `readMainLoaderId`,见 `snapshot()` 的注释)。 净开销 = 每次 mutate **+1 次 CDP 往返**(粗、细合成一条脚本,中门 `loaderId` 不做:它能抓的 「地址不变的整页刷新」必然产生新文档,旧 `backendNodeId` 在门**之前**就被 `resolveNodeObjectId` 兜成 stale)。 **挡不住的**:同 URL 局部改版、列表重排复用节点、`adopt()` 就地改绑 —— 见 `docs/多会话防冲突-实施方案.md` §5.2(D-5)。 ### 四个原语,别混用 | 原语 | 谁用 | 行为 | |---|---|---| | `publish()` | **全页快照** | 推进 epoch,**整表换血**;同时记下这一纪元的 `loaderId` 与**发布时刻的顶层文档 url**(`epochUrl`,写前门的比对依据) | | `adopt()` | **区域快照** | 往当前纪元**追加**映射,**不推进 epoch、不改纪元 url**。区域快照绝不能用 `publish()`,否则取个区域就把之前攒的 ref 全打光 | | `restore()` | **revalidate** | 把**同一 ref 号**装回当前纪元(`adopt()` 会发新号,不能拿来做这件事)。只在纪元**没有** url 时回填当下读到的地址,已有值绝不改写 | | `invalidate()` | 导航 | 清空整张表(连 `loaderId` 与纪元 url 一起清) | ### `semanticKey`(恢复锚点) `semanticKey = djb2(role + name + 稳定祖先路径)`,`\u0000` 分隔。祖先路径跳过动态节点 (随机 id、10 位以上数字串、透明 / ignored 节点)。`list()` 不暴露该字段。 - **只参与匹配决策,绝不参与 ref 生成**。ref 仍是注册表分配的单调序号,永不复用。 - `adopt` 对当前 live 表里**唯一**命中的 key 复用旧号并更新 `backendNodeId`;未命中或多命中发新号。 **同一次 `adopt` 里同一 key 出现两次也算多命中**(否则第一条 mint 后第二条会把号偷走)。 - **实测唯一命中率只有 39%**(33 个可操作元素只有 13 个唯一 key)。撞车集中在「翻译此页」×10、 「查看详细信息」×10 —— 因为 `listitem` 无可访问名称,祖先路径里只剩 `list>listitem`, 真正能区分的「结果标题」是它的**兄弟子树**,不在祖先链上。 - ⚠️ **低唯一率是安全特性,不是缺陷**:约束「唯一命中才复用」正是靠它防错配。 强行把唯一率提到 0.8+,跨文档场景会有大量元素「看起来唯一命中」→ **错误复活旧 ref**。 所以**别用序号 / nth 补精度** —— 那会让「列表中间插入一条」导致后面全体错位,反而降低 unchanged。 - **跨文档(loaderId 变)禁用 semanticKey 复用**,沿用 `revalidate` 的 loaderId 门。 **LRU**:live 表上限 `LIVE_BINDING_CAP = 2000`。超出按 `lastSeen` **软淘汰**(删指针、留 `key` 到 `evicted`), 被淘汰的唯一身份再 `adopt` 可**同号复活**。`evicted.clear()` 在 `publish()` 与 `invalidate()` 都调用 —— 等价于「换文档即清空」。`BROWSER_STALE_REF` 仍只表示「不在 live 表」。 **持久化**:`exportBindings()` / `hydrate()` 只带 `ref → semanticKey`(provider 暂不落盘), **纪元 url 不落盘** —— `hydrate()` 把它清空,所以重启后第一轮写前门没有粗门证据、按 fail-open 放行, 等新进程第一次 `publish` 才重新有依据。 `hydrate` 不把会话标成已观察(第一次 snapshot 前 `resolve` 仍是 `BROWSER_SNAPSHOT_REQUIRED`)。 第一次全页 `publish` 对唯一 key 重绑;缺席或多命中的旧号在 snapshot **之后**才是 stale。 同一进程里后续 `publish` 仍换表发新号。 > 遗留两项(不阻塞):① `evicted` 只在 `publish()` / `invalidate()` 清空 → 上界是「单次快照行数 − 2000」, > **不跨快照累积**(原判「长期会话体积无界」已更正,见 `多会话防冲突-实施方案.md` §10.6), > 但它仍会被 `exportBindings()` 一并落盘,所以超大的一次快照还是会把差额写进盘里; > ② `trimLive()` 每淘汰一条全表扫一次 `lastSeen`,O(k·n),大量新增时会抖动。 ### `webpage_revalidate`(精确恢复) 保留最近 **K=3** 个纪元(条目封顶 2000)。`publish` / `invalidate` 时把旧表连同主 frame `loaderId` 一起推进归档 —— **`Page.getFrameTree` 按需读取,不订阅 Page 事件**(provider 目前不订阅任何 Page 事件, 走订阅要新建管线;按需读取只需一次命令、无状态)。 **恢复顺序不能乱**: 1. 先比对归档 `loaderId` 与当前文档 — 对不上直接 `document_changed`,**不**发 `DOM.resolveNode`。 (导航后 backendNodeId 重新编号,可能撞上旧号并解析到**新文档里的另一个节点**, 若其 role/name 恰好相同就是**静默命中错误元素** —— 这是本设计最忌讳的失效形态。) 2. 通过后 `DOM.resolveNode` → `isConnected` → `getPartialAXTree` 核 role/name。 3. 最后 `restore()` 把同一 ref 号装回当前纪元。 失败按条报:`document_changed` / `node_gone` / `identity_mismatch` / `not_archived`。 `STALE_NOTICE` 改为「先 revalidate,失败再 snapshot」。 > **跨文档不会静默点错,CDP 自己挡住了**:实测页面 A(22 个按钮)人工导航到 B 后, > 用 A 的旧 `backendNodeId` 逐个 `DOM.resolveNode`,**22 个全部失败**。 > 所以「点在新文档的另一个元素上」这条事故链不成立。 > **已知挡不住的**:列表重排时 DOM 节点被复用来显示另一条数据 —— `backendNodeId` 不变、 > `ancestorPath` 仍是 `list>listitem`、role/name 也不变,现有所有机制都漏。收益/成本不划算,记着不做。 --- ## 6. 大纲:折叠 + 同名链去重 ### 不变量(改这个模块前先记住) 1. **折叠只影响展示层,不影响寻址层。** `rows` / `RefTarget` 表**永远全量分配**, `publish()` 行为不变;折叠只减少打印的行。理由:折叠标记对模型承诺「用 `webpage_find` 拿全部实例的 ref」, 底稿里少了实例这句承诺立刻是假的。ref 表本身不进上下文,全量分配零成本。 2. **必须「先折叠、再结算预算」**。折叠若发生在预算之后,省下的行额换不到任何正文,折叠就只剩「看着清爽」。 代价是不能「超预算即停止下钻」(重复计数要看完整棵树)—— AX 树本来就整棵在内存里。 3. **三套计数分开报**:`truncated` / `droppedElements`(预算不够、没输出)、`foldedRepeats`(重复、没打印,元素都还在 refs 里)。 ### 折叠规则 | 规则 | 为什么 | |---|---| | 全局 `(role, name)` 计数 **≥ 4** 才折 | 同父计数折不到 SERP(每条结果的按钮挂在不同 listitem 下);跨父 ≥10 又够不着 8~10 条结果的页面 | | 只折**可操作 + 有名字**的行 | 无名 listitem 折了会把列表结构抹平 | | `statictext` 永不折叠 | 标题 / 摘要正是区分两条结果的正文 | | 子树里只能有**与自身同名的标签行** | 夹带真内容的容器折了就是删信息 | | 折叠单元 = 实例行 + 它的同名标签行 | 见下 | **真实 AX 树的形状(必须照它画夹具)**:`` 会给出 `button "翻译此页"` **加一个子节点** `text "翻译此页"`。早期「有已打印子行就不折」的护栏因此 **真实页面上一个都折不到**,而单测夹具里没画这个子节点、断言全绿。 结果标题同理是 `heading "X" → link "X" → text "X"` 三行同文。 `foldedRepeats` 只数**实例行**(`Σ(×N − 1)`),跟着收起的同名标签行不计(它不是独立元素、没有 ref), 这样 `Σ(标记里的 ×N − 1)` 与 `foldedRepeats` 永远对得上。 ### 同名链去重 与折叠是**两回事**(折叠管平级/跨父的重复实例,去重管同一个名字在一条祖先链上被印三遍),另起一套计数。 | 规则 | 为什么 | |---|---| | 只吃**真·祖先链**上的同名行 | 兄弟之间的同名(两条结果摘要相同)不是冗余,是真的两条内容 | | 链上留**可操作**的那行 | 留 `heading` 删 `link` 会删掉寻址能力 | | 链上没有可操作行时,留「渲染文本最长」的那行 | 越长带的属性越多(`level` / `url` / `disabled`) | | 链上有 **2 个以上**可操作行就整条不动 | 留谁都是猜,省一行换不来少一个能点的元素 | **底稿(find 的检索底稿)的定义 —— 这条最容易错**: > 底稿 = `lines`(打印行,去掉折叠标记)∪ `foldedInstances`(被折叠掉的实例行) > > 也就是「模型看到的那份,加上它看不到的实例」。**刻意隐藏的副本行一行都不进去。** 原定义是「所有原始行」,会造出**幻影命中**:搜「翻译此页」返回 5 条正确命中, **外加 5 条 `ref: ""` 的空命中**(按钮下面那行同名标签)。模型手里的大纲根本没有这行,find 却报出来。 **缩进断层**:抽掉链中间的 `heading` 后,留下的 `link` 还顶着原来的 depth(4), 而同 listitem 里的摘要是 depth 3 —— 两份同属一节的行看起来像大纲坏了。修法是 `normalizeDepths()`: 每行 depth 重算成「保留行里还有几层祖先」。**打印视图与底稿各归一各的**(底稿含折叠实例,归一结果本就不同)。 --- ## 7. `webpage_wait until: 'stable'` 第四种模式(另三种是 `time_ms` / `text` / `ref`,描述写死「Exactly one of」—— 加模式时必须同步改校验与文案, 否则模型传 `until` 会被互斥校验拒掉)。 判定:`readyState complete` 是**前置**, `stable = readyState && domQuiet && (networkQuiet || 网络忙已持续超过宽限期)`。 - **DOM 静默**:页面内 MutationObserver 计数探针(幂等安装,导航后重装),「自上次读取以来增量 = 0」连续两个安静窗口。 - **网络静默**:`NetworkCollector.inflight`(`requestWillBeSent` +1,`loadingFinished`/`loadingFailed` −1)== 0 持续一个窗口。 - **宽限期 3s** 管心跳 / 长轮询站点:网络信号只能加权,不一票否决。 - 超时**不报错**,回执 `satisfied: false` + `signals: { readyState, dom, network }`。绝不把慢页面谎报成稳定。 - 该模式窗口默认取大值(对齐 `MAX_WAIT_TIME_MS = 30_000`),允许 `timeout_ms` 覆盖,硬上限不得超工具超时。 **AI 问答「回答完成」是同一套信号的特例,零站点特判**:流式请求关闭(最强,SSE/fetch 流吐字结束这条请求才关闭) → 回答容器文本停止增长(旁证)→ 停止按钮消失(仅佐证)。**不写站点黑名单。** **范围边界**:**不把这套接进 click / press 的自动 settle** —— 否则每次 click 都多等一个安静窗口(≥1s),是回归。 **探针可被篡改**:`window.__dsh_mut` 是页面全局,而 `webpage_execute` 的白名单**允许 `Runtime.evaluate`**。 两条处置:① 判定不得只依赖探针(多信号结构本身就是防线);② 回执不暴露探针的全局名与计数器语义。 --- ## 8. 区域快照 `webpage_snapshot` 的 `region` | 参数 | 语义 | 实现 | |---|---|---| | `ref` | 只出该元素子树的大纲 | `Accessibility.getPartialAXTree(backendNodeId)`,返回结构与 `getFullAXTree` 完全一致 → `buildOutline` 零改动复用 | | `viewport: true` | 只出当前视口内可见元素 | `Page.getLayoutMetrics` + `DOMSnapshot.captureSnapshot`,布局盒与视口矩形求交 | | `box: {x,y,width,height}` | 只出与几何矩形相交的元素 | 同上,矩形换成调用方给的 | - `captureSnapshot` 的 `boundingBox` 是 **quad(四角)**,要取 min/max 转 AABB 再求交。共 2~3 个 CDP 调用, 不需要逐元素 `DOM.getBoxModel`。 - 可见性判定:与区域**有交集即算**(部分可见的元素不该丢 —— 模型正要滚向它)。 - 三种形态**都走 `adopt()`**,回执 `outside_region`,**不覆盖全页 find 缓存**。 - `ref` 形态的锚点 ref **必须来自当前纪元**(stale 直接报 `BROWSER_STALE_REF`)。 - 区域内输出走同一套折叠逻辑。`outside_region` 与 `folded_repeats` 是两套独立计数,分开报。 --- ## 9. 上下文预算(P2 read 型工具) `webpage_console` / `webpage_network` 返回的是**页面产生的、模型无法预期的**文本,条数上限挡不住总量。三道闸门缺一不可: | 闸门 | 管什么 | console | network | |---|---|---|---| | 条数 `limit` | 条数 | 默认 50,上限 `MAX_P2_LIMIT = 150` | 同左 | | 单条裁剪 | 单条最长 | `CONSOLE_TEXT_MAX_CHARS = 2000` | list 的 URL **不截**(要给模型复制出去用);body 文本 20000 / **base64 只有 2000** | | 总量预算 | 一次返回的总字符 | `CONSOLE_RESULT_MAX_CHARS = 40000` | `NETWORK_LIST_MAX_CHARS = 40000` | - **base64 body 单独压到 2000**:它编码图片/字体/wasm,模型既解不出来也读不懂,2 万字符只是纯噪声。真要看图走 `webpage_screenshot`。 - **`truncated`(条数)与 `truncatedByBudget`(总量)必须分开报**,因为「怎么拿到更多」的答案不同: 条数到了 → 调大 `limit`;总量到了 → 用 `level` / `text` / `url` **过滤**。给错建议比不给更糟。 - **第一条永远返回**,否则模型连「有什么可过滤的」都不知道。 - 闸门在 push **之前**判 `>=`,所以放行条数 = `ceil(预算 / 单条长度)` —— 测试按这个式子算期望值,别写死常数。 --- ## 10. `webpage_execute`(唯一的高危入口) **用允许列表(默认拒),不用拒绝列表。** 理由:命令不断加入意味着黑名单永远追不上, 任何未列出的新命令默认**放行**,与本仓「处处保守」的哲学自相矛盾。错误消息里带上被拒的 `domain.method` 全文。 **允许列表**(只读 / session 私有 / 只导航): `Runtime.evaluate`、`Runtime.getProperties`、`DOM.getDocument`、`DOM.querySelector`、 `Page.navigate`、`Page.reload`、`Page.captureScreenshot`、`Accessibility.getFullAXTree`、 `Network.enable`、`Network.getResponseBody`、`Log.enable`。 **必须拒绝的**:`Target.*`(`attachToTarget` 能拿其它标签页的 session)、`Browser.*`(`close` 关整个浏览器)、 `Emulation.*`、`Fetch.*`、`Overlay.*`、`Input.*`、 `Network.emulateNetworkConditions` / `setExtraHTTPHeaders` / `setCacheDisabled`、 `Page.addScriptToEvaluateOnNewDocument` / `removeScriptToEvaluateOnNewDocument`。 (逐条的实测依据见 `CDP实测事实.md`。) **三条已落地的行为**: 1. `Runtime.evaluate` **强制 `returnByValue` + `awaitPromise` + `userGesture`**。 不带 `userGesture` 时 `clipboard.writeText` / `requestFullscreen` / `window.open` / 媒体自动播放一律被页面拒 `NotAllowedError: Transient user activation is required` —— 这是「执行不了 JS」最常见的形态。 2. **`awaitPromise: true` 强制等待**,长期不落定的 Promise(流、轮询循环)会阻塞到超时。 文案已写明出路(用 `Promise.race([...])` 包一层)。不引入异步回执。 3. **`isEmptyObject` 维持拒绝** `{}`。`document.body` 静默 `{}` 与合法空对象在序列化结果上无法区分 (subtype 也不是 node),放行会让模型拿到垃圾 `{}` 当成功。新文案写明出路(`JSON.stringify({})` / 取具体字段), DOM 节点与空对象走**两条不同**的针对性文案。 **返回值三态**(见 `CDP实测事实.md` V22):强制 `returnByValue`; **不能只判断 `result.value === undefined`** —— DOM 节点那种情况 `value` 是个 `{}`,看着"有值"其实是垃圾, 要**同时检查 `result.type` / `result.subtype`**。 **配套文案**:`Input.*` 被拒时引导到 `webpage_press` / `webpage_fill` / `webpage_scroll`; `webpage_press` 描述写明只能产生**单个 ASCII 字符**(CJK 要用 `webpage_fill`); `webpage_fill` 描述写明 contenteditable 走原生输入管线(见下)。 ### fill / press 的两个已修 bug - **`key: "Space"` 曾被拒**:`KNOWN_KEYS` 里只有单字符 `' '`,但**错误文案与工具描述都把 `Space` 当命名键宣传**。 修法:抽出 `SPACE_KEY` 常量(`{ key:' ', code:'Space', virtualKeyCode:32, text:' ' }`),两个键名共用。 **宣传的名字必须真的能用。** - **fill 对 contenteditable 失效**:原来对非 input/textarea 一律 `element.textContent = value`, 不触发 `beforeinput`,Lexical / ProseMirror / Slate 等富文本框架内部状态不更新、发送按钮不亮。 修法:页面内函数三分支(`'value'` / `'editable'` / `'text'`),`contenteditable` 分支只做**聚焦 + 全选** (`selectNodeContents`),真正的写入由 provider 随后发 **`Input.insertText`** —— 走浏览器原生输入管线。 **必须先全选**:`insertText` 是「在选区处插入」,否则新值会拼接到旧内容后面。 --- ## 11. target 级状态簿记与人工接管 ```ts targetState: Map> ``` **进簿记的命令**(全部实测为跨 client 共享 / 后写赢): | 命令 | 实测作用域 | |---|---| | `Emulation.setDeviceMetricsOverride` / `setUserAgentOverride` | 跨 client 覆盖,后写赢 | | `Network.emulateNetworkConditions` / `setExtraHTTPHeaders` | 跨 client 覆盖,后写赢 | | `Page.addScriptToEvaluateOnNewDocument` | 跨 client 共享(别人触发的导航也吃注入),但注册项随注册者 session detach 清除 | | `Page.bringToFront` | 共享、最后调用者赢(**不是锁**,是 target 级操作) | **不进簿记**(session 私有):`Network.setBlockedURLs`、`Runtime.setAsyncCallStackDepth`。 > ⚠️ **同一 domain 内的不同命令作用域也可以不同**(`setBlockedURLs` 私有、`emulateNetworkConditions` 共享)。 > **每接入一个写状态的新命令都要单独实测,默认按「共享」处理。** 判据不是「哪个 domain」, > 而是「**这条命令写的是不是 target 级行为状态**」。 **`owner: 'human'` 从哪来**(CDP 不广播其它 client 的命令,只有自己 enable 过的 domain 的事件才收得到): | 来源 | 规则 | 适用 | |---|---|---| | ① 粗粒度让渡(**主力**) | 接管语义激活期间,该 target 的 target 级状态整体视为 `human` 所有 | 全部 key | | ② 延迟回读探测 | 写入后**延迟 ≥170ms** 再回读,读回值 ≠ 本次写入 → 判 `human` | **只对有读取面的 key 成立**(`Emulation` 两条可读;`Network` 那两条没有 get 命令) | **冲突判定条件**(避免「第一次设置就被判争用」的死锁): | 情形 | 处置 | |---|---| | `owner === 'agent'` 且探测到值被外部改写 | 抛 `BROWSER_STATE_CONTENDED` | | 处于接管窗口内 | 抛(整体让渡) | | **从没被 agent 写过** | **允许写**,回执标注「此前来源未知,可能来自人工」,旧值留在 `previous` | **`BROWSER_STATE_CONTENDED` 必须有恢复路径**:payload 带 `stateKey` / `holder` / `at`; 错误**不可重试**(原样重发必然再失败,这句要写进错误消息正文);允许的唯一「抢」的姿势是显式 `force: true`, 并把被覆盖的 `previous` 写进回执。 **接管语义 = agent 停止操作 + 重新观察,不是断开连接。** 三件事:① 通知(观察失效)② 归属簿记 ③ 前台协商。 - **通知通道**:宿主新增一条独立编排消息 `{ type: 'takeover', tabId, active }`(**不进 CDP 事件流** —— 那条通道的职责是搬运 CDP,混进私有 method 会让「这哪来的 CDP 事件」变成下一个人要查的问题)。 触发点是 `host.cjs` 里 `view.webContents.on('devtools-opened'/'devtools-closed')` —— 这两个事件 对「agent 触发」和「人工触发」一视同仁,正好补上「人工走菜单/快捷键时 agent 完全不知情」的缺口。 - **`webpage_snapshot` 结果带 `takeover: true`** + 「人工正在操作,本结果可能随时失效」;其它读型工具照常可用。 - **ref 纪元不发生任何变化。** 推了就等于每次人工开关 DevTools 都把模型的 ref 全废掉。 - `active` 是幂等状态位不是计数器,重复触发不用去重。 **观察失效的事后检测**(mutate 之后):`detectNavigation`(`provider.ts:1971`)比对 `location.href` 与导航前的值,变了就推进 ref 纪元并报 `navigated`;动作**之前**还有一道 §5 的写前门。 > **两处都是启发式,不是完备判定**。`href` 不变的 SPA 内部重渲染抓不住, > 且节点被复用来显示另一条数据时也没有廉价检测手段。**兜底在「现算 rect」那一步,且判据是三个不是一个**: > `resolveNode` 抛错 → stale;`isConnected === false` → stale(**只查 resolveNode 会漏** —— 元素被 `replaceWith` > 换掉后 resolveNode 仍成功);rect 宽高为 0 → 报「不可点击」。**locate 必须始终现算 rect,绝不缓存 snapshot 时的几何值。** --- ## 12. 工具清单 **16 个**工具,前缀 `webpage_`(`browser_*` → `webpage_*` 的更名动机:让 agent 以为要「调用浏览器软件」 而不是「操作网页」;选 `webpage_` 是因为 dsh 内置已有 `web_search` / `web_fetch`,`web_` 会撞车)。 **错误码仍是 `BROWSER_*`**(协议不变量,不随工具名走)。 | 能力级 | 工具 | |---|---| | `read` | `open` / `navigate` / `snapshot` / `screenshot` / `wait` / `console` / `network` / `find` / `locate` / `revalidate` | | `mutate` | `tabs` / `click` / `fill` / `press` / `scroll` / `execute` | 分级表是 `BROWSER_TOOL_CAPABILITIES`(`src/tool-browser/index.ts`,`Object.freeze` 的只读记录), 作为元数据导出供策略层消费。**新增工具必须同时登记进这张表**, 否则 `read`/`mutate` 的判定会被架空 —— 这正是 `webpage_execute` 必须拒掉 `Input.*` 的理由。 几条容易漏的行为契约(都在源码注释里,改动时别弄丢): - `webpage_locate` **默认不滚视口**(`scroll` 默认 `false`),只量当下坐标并回 `in_viewport` —— 这样它才能用来验证 `webpage_scroll` 是否生效。要居中得显式 `scroll: true`。 - `webpage_scroll` 的 `ref` **可省**:不给就落在视口中心,因此长页 / 零 ref 页也能用,不必先 snapshot。 - `webpage_console` / `webpage_network` **默认只给当前文档**,并如实报 `earlier_documents` 计数; `all_documents=true` 可读全部。**过滤不等于丢弃。** - 时间戳**不写死单位**:按量级归一(秒 / 毫秒 / 微秒 → 毫秒)。曾因硬编码微秒让 Runtime 比 Log 小 1000 倍。 - 空标题渲染成 `title: (empty — …)`;零 ref 的页面显式说明「没有可操作元素」并给出替代动作 (不带 ref 的 scroll / navigate / execute)。空白或沉默都不行。 - **`webpage_click` 带落点校验**(2026-09-19,`webpage交互改进` B1-d):`scrollIntoView` 之后、派发之前, 对落点问一句 `document.elementFromPoint`。命中别的节点时**事件照发**,但回执必须带 `occluded_by` (role / name / hint)。**不做**自动 Escape、也不自动改点遮罩上的按钮 —— 那两样都是误触。 未导航的回执按「遮挡 → 有 href(报 role/name/href + 下一步)→ 可能是 JS 控件(去 snapshot)」给指引。 - **`webpage_scroll` 的回包等待是独立的 2s**(`WHEEL_ACK_TIMEOUT_MS`),与 `commandTimeoutMs`(30s)脱钩: 滚轮在后台标签 / Electron 上可能根本不回包,不脱钩时一次 scroll 就把工具卡满 30s。 **超时不是失败** —— 事件已投递,回执 `unconfirmed: true`(位置自己去 snapshot / locate 确认)。 目标不在前台时先 `activate`;transport 切不动前台就报 `BROWSER_NOT_IMPLEMENTED` 明确拒绝。 - **`webpage_navigate` 还有 `history: back | forward | reload`**(与 `url` 恰好一个): 走 `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`(`reload` 走 `Page.reload`), 历史尽头报 `BROWSER_NAVIGATION_FAILED`(`cannot go back` / `cannot go forward`),**绝不静默 no-op**。 回到上一页走这里,不要再 `webpage_execute("history.back()")`。 - **全页 snapshot 会在大纲顶部插 `OVERLAY at viewport center: …`**(B2-d):视口中心命中链上存在 `fixed`(或 `absolute` + 非 auto z-index)且盖住视口 60% 以上的祖先时插这行 —— 浮层在 AX 里排在 `` 末尾,小 `max_lines` 会把它整段截掉,于是模型误以为「页面可以直接点正文」。 区域快照不查(本来就是只看一块)。 `webpage_locate` 的定位链路:**`backendNodeId` → `DOM.resolveNode` → `Runtime.callFunctionOn` 现算 rect**。 不用 `DOM` 的 nodeId(**重复 `getDocument` 会导致 nodeId 重新分配**,同一元素 6→13,且各 session 独立编号), 也不用 selector(AX 树出来的元素未必有唯一 selector;iframe / shadow DOM 里直接落空; **最要命的是它会静默命中重构后的另一个元素**)。 > 高亮一律用 `Overlay.highlightNode` + `highlightConfig`,**绝不用 `highlightRect`** —— 后者会把传入 rect > 之外的**整个视口**都染成浅色(实测红像素 ≈ 视口面积 82%)。高亮不是单例,多 client 可共存、互不取消。