DESIGN DOC · 2026-05-13 · ISSUE: pinned-tab restricted URL race
Navigation Transient Tolerance
让 agent loop 顶部 origin check 与 open_url handler 在页面 navigation in-flight 期间不再误判
about:blank 为 restricted URL —— 双保险结构覆盖 open_url / focus_tab / click /
页面自跳 / 用户操作 共 6 条 navigation transient 路径。
1 · 现状:撞 race 的链路
Loop 顶部 origin check (loop.ts:912) 每轮起手读
chrome.tabs.get(pinnedTabId),命中
isRestrictedUrl(url)(含 about: 前缀)即 hard-stop。
问题:navigation in-flight 的 tab.url 短暂为 about:blank,被误判。
LLM tool call
SW Service Worker / agent loop
Chrome 浏览器 API
WAIT 应该等
STOP 任务终止
OK 正常 settle
现状时序 — 用户报告的失败场景
T0
LLM
open_url("https://docs.example.com") 慢站,冷启 1–3s
T0+5ms
SW
chrome.tabs.create({url}) 立即返回 tab.id=42,
tab.url="about:blank",status="loading"
T0+8ms
SW
appendPinnedTab({tabId:42, origin:"https://docs.example.com"}) 写入 storage
T0+12ms
SW
handler 返回 success,进入下一轮(同轮 LLM 已 batch focus_tab(42))
T1
SW
下一轮顶部 readFocusFromStorage → focused = tab 42
T1+1ms
Chrome
tabs.get(42) 返回 url="about:blank"(navigation 仍 in-flight)
T1+2ms
STOP
isRestrictedUrl("about:blank") ⇒ true(startsWith("about:"))
summary = "Page navigated to a restricted URL, agent stopped"
T2 ≈ T0+1s
Chrome
webNavigation.onCommitted 才触发 → tab.url 才变 https://docs.example.com
太晚了,loop 已经在 T1+2ms 终止
结构性原因: isRestrictedUrl 不区分 "navigation 未 commit 的初始 sentinel" vs
"页面真停在 chrome:// / data: / file:// 等无法操作的 origin"。
2 · 方案:双保险时序
两道护栏:open_url handler 内等 commit(第一道)+ loop 顶部 origin
check 之前等 commit(第二道,统一收敛点)。任一 path 漏掉,另一道接住。
修复后时序 — 同样场景,双保险版
T0
LLM
open_url("https://docs.example.com")
T0+5ms
Chrome
chrome.tabs.create({url}) 返回 tab.id=42 (about:blank)
T0+8ms
WAIT
waitForUrlSettle(42, "https://docs.example.com", 5000)
注册 chrome.webNavigation.onCommitted listener(frameId=0, tabId=42)
T0+1.2s
Chrome
onCommitted 触发 → tabs.get(42).url = "https://docs.example.com/..."
origin 匹配 ⇒ resolve {committed: true, url}
T0+1.2s
SW
appendPinnedTab + 返回 success
T1
SW
下一轮起手,tab.url 已是目标 URL → 标准 origin check 通过
T1+2ms
OK
snapshot → 继续 ReAct 循环
容错性: 即便 handler 内 wait 漏(比如未来加新的 navigation 触发路径),loop
顶部第二道也会接住所有 transient。
3 · 架构:模块组织
wait-for-url-settle.ts NEW
src/lib/agent/wait-for-url-settle.ts
- navigation-commit 等待 helper
chrome.webNavigation.onCommitted(frameId=0)+ timeout cap
- 5 种 resolve 结果:committed / timeout / origin-mismatch / tab-gone / aborted
- signal 支持 — 与 loop internal abort 协作
- try/finally cleanup listener
wait-for-settle.ts 不动
src/lib/agent/wait-for-settle.ts
- 现有
withActionSettle — click / type / keyboard 用
- 语义:action 后等 page 安静(quietMs window)
- 与新 helper 语义独立但共享 webNavigation API
- 不重构、不复用,避免污染稳定模块
loop.ts MODIFIED
src/lib/agent/loop.ts:912
- 顶部 origin check 加一道 transient 容忍
- trigger:
!url || url === "about:blank"
- fast-fail: pendingUrl origin 不匹配立刻 STOP
- 否则
waitForUrlSettle(... 5000)
- fall-through 到现有
isRestrictedUrl + origin check
tabs.ts MODIFIED
src/lib/agent/tools/tabs.ts:1158 (openUrlTool)
- chrome.tabs.create 之后 await
waitForUrlSettle
- commit 失败 → handler-level fail(不 STOP task)
- 不撤销失败的 tab —— 留给 LLM 自决 close_tabs
- error message 携带 reason(timeout / origin-mismatch)
不动的模块
| 模块 | 原因 |
| isRestrictedUrl (loop.ts:319) | 保持 about: 拦截语义;transient 容忍在调用之前判断 |
| safeParseOrigin | 仅给 fast-fail 用 pendingUrl 解析 |
| focus_tab handler | 仍是 always-low 纯指针更新;下一轮顶部 settle 接住 |
| withActionSettle | click/type/keyboard 现有 settle 稳定,不动 |
| manifest.json | webNavigation permission 已就位(recording v1 引入) |
4 · 决策矩阵(4 个锁定点)
Scope
双保险
loop 顶部 + open_url handler 同时改。loop 顶部是统一收敛点,open_url 内 wait 提升 handler 错误清晰度。
Timeout
5000 ms
够 cover Gmail / GitHub / 飞书 冷启;不会让 LLM 在面板前哑等过久。比现有 withActionSettle max=3000ms 长,因为新 tab 加载比单次 click 慢。
pendingUrl 策略
fast-fail
pendingUrl 存在 + origin 不匹配 ⇒ 立刻 STOP(节省 5s wait)。其他情况 wait + timeout 后走标准 check。安全语义同"完全忽略",但漂洗场景出错信息更快。
Timeout 后文案
复用 restricted
沿用 "Page navigated to a restricted URL, agent stopped"。最小改动,不引入新 user-visible 错误类型;用户从 panel 视角行为一致。
5 · 错误处理矩阵
| wait 结果 | loop 顶部行为 | open_url handler 行为 |
| commit ≤5s + origin 匹配 | 更新 currentUrl,继续 snapshot | success: true + appendPinnedTab |
| pendingUrl fast-fail(origin 不匹配) | STOP "origin changed" | 理论不到此分支 |
| onCommitted 报告 origin 不匹配 | STOP "origin changed" | success: false + error |
| 5s timeout,url 仍 transient | STOP "restricted URL"(复用文案) | success: false + "did not commit within 5s" |
| tab 关闭(tabs.get reject) | STOP "Tab was closed" | success: false + "tab gone" |
| signal aborted(user Stop) | finally 走标准 abort emit | handler ctx signal 协作 |
6 · 覆盖矩阵(6 条 race 路径)
| navigation 触发路径 | 谁接住 | 备注 |
| open_url 创建新 tab | open_url handler settle | 主要场景;用户报告的原 race |
| open_url + 同轮 focus_tab + 慢站 | open_url settle 已等到 → loop 顶部不再 transient | 双保险都不需要触发 |
| click 触发 cross-doc nav | 现有 withActionSettle + loop 顶部 settle | quietMs=500 不够时顶部接住 |
| type + Enter 触发 form submit | 同上 | 同上 |
| 页面 JS 自跳 / meta refresh | loop 顶部 settle | 无 handler 可挂 |
| 用户手动操作 pinned tab(刷新 / 回退) | loop 顶部 settle | 同上 |
| focus_tab 切到加载中 tab | loop 顶部 settle | focus_tab handler 仍是指针更新,不挂 wait |
7 · 关键代码改动
新 helper 接口
// src/lib/agent/wait-for-url-settle.ts
export type UrlSettleResult =
| { committed: true; url: string }
| { committed: false; reason: "timeout" | "origin-mismatch" | "tab-gone"; observedUrl?: string };
export async function waitForUrlSettle(
tabId: number,
expectedOrigin: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<UrlSettleResult>;
Loop 顶部改造
const currentTab = await chrome.tabs.get(pinnedTabId);
+ // transient 检测 — B 核心
+ if (!currentTab.url || currentTab.url === "about:blank") {
+ if (currentTab.pendingUrl) {
+ const pendingOrigin = safeParseOrigin(currentTab.pendingUrl);
+ if (pendingOrigin && pendingOrigin !== pinnedOrigin) {
+ await emitDone(STOP_origin_changed, "abort"); return;
+ }
+ }
+ const r = await waitForUrlSettle(pinnedTabId, pinnedOrigin, 5000, signal);
+ if (!r.committed) {
+ if (r.reason === "origin-mismatch") await emitDone(STOP_origin_changed, "abort");
+ else if (r.reason === "tab-gone") await emitDone(STOP_tab_closed, "abort");
+ else /* timeout */ await emitDone(STOP_restricted, "abort");
+ return;
+ }
+ currentUrl = r.url;
+ }
if (!currentTab.url || isRestrictedUrl(currentTab.url)) { /* existing */ }
// origin check 继续 ...
open_url handler 改造
newTab = await chrome.tabs.create({ url: a.url, active });
if (typeof newTab.id !== "number" || newTab.id < 0) { /* existing */ }
+ const r = await waitForUrlSettle(newTab.id, parsed.origin, 5000);
+ if (!r.committed) {
+ return {
+ success: false,
+ error: `open_url: tab ${newTab.id} created but navigation did not commit to ${parsed.origin} within 5s (${r.reason}).`,
+ };
+ }
if (ctx.appendPinnedTab) { /* existing */ }
return { success: true, observation: ... };
8 · 测试策略
| 文件 | 覆盖 |
wait-for-url-settle.test.ts NEW |
onCommitted 匹配 origin → committed:true ·
不匹配 → origin-mismatch ·
5s 无事件 → timeout ·
tabs.get reject → tab-gone ·
signal abort → 立即 return ·
listener cleanup(commit + timeout 两路径都验证)·
re-entrant 两 tabId 各自 listener 不串
|
| loop.test.ts (扩) |
transient + commit 内 5s → 继续 snapshot ·
pendingUrl mismatch fast-fail → STOP "origin changed" ·
5s timeout → STOP "restricted URL"(断言文案沿用现有)·
非 transient url → 不调 settle(节流校验)
|
| tabs.test.ts openUrlTool (扩) |
create + commit 成功 → success path ·
commit timeout → handler fail + 描述性 error ·
chrome.tabs.create reject → 现有 path 不动
|
| cross-layer regression |
panel 收到 done summary,"restricted URL" 仅在真 timeout 时出现,
慢站场景 commit 成功不再误报
|
9 · Out of scope(明确不做)
| 不动 isRestrictedUrl | about: 拦截语义不变;transient 容忍在调用前判断 |
| 不动 withActionSettle | click/type/keyboard 现有 settle 稳定,不重构 |
| 不引入新错误文案 | timeout 复用 "restricted URL" 文案(决策 4) |
| 不撤销失败的 tab | open_url commit 失败保留 tab,LLM 自决 close_tabs |
| 不动 manifest | webNavigation permission 已就位 |
| 不复活 confirm 路径 | confirm 层 2026-05-08 已删,本期严守 |
| 不动 focus_tab handler | 仍是 always-low 指针更新;下一轮顶部 settle 接住 |