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=42tab.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 接住
withActionSettleclick/type/keyboard 现有 settle 稳定,不动
manifest.jsonwebNavigation 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,继续 snapshotsuccess: true + appendPinnedTab
pendingUrl fast-fail(origin 不匹配)STOP "origin changed"理论不到此分支
onCommitted 报告 origin 不匹配STOP "origin changed"success: false + error
5s timeout,url 仍 transientSTOP "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 emithandler ctx signal 协作

6 · 覆盖矩阵(6 条 race 路径)

navigation 触发路径谁接住备注
open_url 创建新 tabopen_url handler settle主要场景;用户报告的原 race
open_url + 同轮 focus_tab + 慢站open_url settle 已等到 → loop 顶部不再 transient双保险都不需要触发
click 触发 cross-doc nav现有 withActionSettle + loop 顶部 settlequietMs=500 不够时顶部接住
type + Enter 触发 form submit同上同上
页面 JS 自跳 / meta refreshloop 顶部 settle无 handler 可挂
用户手动操作 pinned tab(刷新 / 回退)loop 顶部 settle同上
focus_tab 切到加载中 tabloop 顶部 settlefocus_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 顶部改造

src/lib/agent/loop.ts~L912
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 改造

src/lib/agent/tools/tabs.tsopenUrlTool · ~L1158
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(明确不做)

不动 isRestrictedUrlabout: 拦截语义不变;transient 容忍在调用前判断
不动 withActionSettleclick/type/keyboard 现有 settle 稳定,不重构
不引入新错误文案timeout 复用 "restricted URL" 文案(决策 4)
不撤销失败的 tabopen_url commit 失败保留 tab,LLM 自决 close_tabs
不动 manifestwebNavigation permission 已就位
不复活 confirm 路径confirm 层 2026-05-08 已删,本期严守
不动 focus_tab handler仍是 always-low 指针更新;下一轮顶部 settle 接住