本文同步自 2026 iThome 鐵人賽原文 ,發布日期為 2026-08-14。完整系列可見 iThome 系列頁

安安~我是ChiYu~

昨天介紹的 Declarative API 很適合表單:只要替既有 HTML 補上標註,瀏覽器就能整理出 Tool 契約。表單待在頁面上,Tool 也跟著待在頁面上,兩邊的生命週期幾乎黏在一起。

但活動詳情不是一張等著送出的表單。它要讀取目前頁面的活動、跟著 route 切換情境,離開 頁面後還得把舊能力收回來。這種需要由 JavaScript 主動決定 Tool 何時存在的工作,就輪到 Imperative API 上場了。

今天要做的 get_event_details 只在特定頁面情境下有意義。使用者離開後,這支 Tool 理論上 也該一起退場;不然畫面早已切走,舊 handler 卻還握著上一場活動的 ID,Inspector 的 Tool 清單也可能繼續把它當成可用能力。

我第一次實作時只記得 registerTool(),完全沒想過要解除。結果它很像一位已經換班,卻 還把鑰匙放在口袋裡的值班人員:人走了,權限沒交回來。

所以今天不只介紹 Imperative API 怎麼註冊 Tool,也要把後半段補完整:它何時出現、何時 不能再被發現,以及回到有效頁面時,應該重新註冊還是沿用舊 handler。

先切到具備 Tool 生命週期的版本

今天的程式里程碑是 v3-day-12。這個版本在昨天的 Declarative 表單之外,加入 ToolRegistryAbortSignal 與可重播的 lifecycle Lab。先切到固定版本並啟動網站:

git switch --detach v3-day-12
npm ci
npm run dev

接著開啟:

http://127.0.0.1:5173/labs/day-11-tool-lifecycle/index.html?route=list

資料夾仍保留早期開發名稱,但本文的版本座標就是 v3-day-12。今天要驗證的不是正式活動 詳情頁,而是更基礎的問題:Tool 能否隨 route 正確註冊、解除、去重,並在暫時失敗後恢復。

Imperative API 是用 JavaScript 動態註冊網站能力

Declarative API 是在既有 HTML form 上加標註;Imperative API 則由 JavaScript 明確定義 一支 Tool 的名稱、用途、輸入格式與執行行為,再透過 document.modelContext.registerTool() 註冊到目前頁面的 model context。

它適合處理的不只有讀取資料,也包括頁面導覽、狀態管理,以及其他難以用單一表單描述的 操作。以今天的 Lab 來說,get_event_details 的最小結構可以整理成這樣:

const detailsTool = {
  name: "get_event_details",
  description: "依活動 ID 讀取公開活動資訊。",
  inputSchema: {
    type: "object",
    additionalProperties: false,
    required: ["event_id"],
    properties: {
      event_id: { type: "string", description: "公開活動 ID。" }
    }
  },
  annotations: {
    readOnlyHint: true,
    untrustedContentHint: true
  },
  execute: async (input) => ({
    ok: true,
    eventId: input.event_id ?? null
  })
};

如果直接使用瀏覽器 API,註冊動作會是:

await document.modelContext.registerTool(detailsTool);

專案沒有讓每個頁面各自呼叫這行,而是包進 ToolRegistry 與 model-context adapter。這層 包裝不是要發明另一套 WebMCP,而是集中處理 route 切換、重複註冊、解除與失敗恢復。Chrome 官方文件也已註記:navigator.modelContext 自 Chrome 150 起棄用,因此本文統一使用 document.modelContext

把昨天與今天放在一起看,兩種 API 的分工會清楚很多:

比較項目Declarative APIImperative API
契約來源HTML form 與欄位標註JavaScript Tool 物件
適合情境搜尋、篩選與既有表單動態內容、非表單操作與 route context
input schema瀏覽器依表單語意整理開發者明確撰寫
生命週期通常跟著 form 出現或移除由程式註冊與解除

知道怎麼把 Tool 註冊進去後,真正麻煩的才正要開始:目前頁面已經不需要它時,誰負責請它 離場?

route 切換時的 Imperative Tool 生命週期

圖 1:Lab 用 list 與 about 兩個 route 隔離生命週期問題。離開有效情境後,Tool 必須從目前清單移除;回來時才重新註冊。

Tool 清單應該反映現在的頁面,不是一路累積歷史紀錄

為了先把問題縮小,Lab 只保留兩個 route:

  • list:代表 get_event_details 可以存在的有效情境。
  • about:代表不該公開這支 Tool 的頁面。

我沒有讓 ToolRegistry 接收「請再新增哪些 Tool」,而是每次都交給它「現在應該存在的 完整清單」:

await registry.sync(route === "list" ? [detailsTool] : []);

這個差異很重要。若 sync() 只會做加法,使用者每切一次頁面,registry 就多留一段舊 情境;改成 desired state 後,它可以拿目前狀態與目標狀態比較,該補的補上,該離場的就 解除。

for (const [name, controller] of this.controllers) {
  if (!desired.has(name)) {
    controller.abort();
    this.controllers.delete(name);
  }
}

換句話說,registry 保存的不是「這個網站曾經註冊過什麼」,而是「這個頁面現在允許 Agent 使用什麼」。

用 AbortSignal 解除 Tool,不能只把畫面藏起來

知道怎麼註冊 Imperative Tool 後,下一個問題不是「還能再註冊幾支」,而是使用者離開目前 情境時,誰負責把它收回來。

把活動卡片或側邊面板隱藏,只代表人類暫時看不到。只要 Tool 還留在 model context,Agent 就仍可能 discovery 到它。UI 消失和能力撤銷,是兩件不同的事。

Chrome 的 Imperative API 文件 提供的做法,是在註冊時傳入 AbortSignal,需要解除 Tool 時再呼叫 abort()

const controller = new AbortController();
await document.modelContext.registerTool(tool, { signal: controller.signal });
controller.abort();

專案把這段行為包進 registry。每一支已註冊 Tool 都有自己的 controller;route 改變後, 不在 desired set 裡的項目會先 abort,再從本地 map 移除。這樣瀏覽器端與應用程式端看見的 狀態才不會各說各話。

同一個 route 重跑兩次,不能多出第二支同名 Tool

route 更新不一定排隊等我們慢慢處理。兩次 sync() 若幾乎同時進來,都可能在註冊前看到 「目前沒有 get_event_details」,接著各自新增一次。

ToolRegistry 因此用 Promise queue 串行處理更新,並以 Tool name 去重:

sync(tools: ProjectTool<object, unknown>[]): Promise<void> {
  const operation = this.queue.then(() => this.apply(tools));
  this.queue = operation.catch(() => undefined);
  return operation;
}

這裡還藏著另一個小坑。若第一次 register 暫時失敗,queue 不能從此維持 rejected,否則 之後每次 route 更新都會被同一個舊錯誤擋住。呼叫端仍會收到當次失敗,但 registry 會把 內部 queue 接回可繼續工作的狀態,下一次 sync() 才有機會恢復。

Unit test 會同時送出兩次相同的 sync(),確認 register 只發生一次;接著故意讓第一次 register 失敗,再驗證第二次可以成功,最後切成空清單時 controller 確實已經 aborted。

Tool 離場要可控,執行結果也要讓呼叫端看得懂

生命週期穩定後,我也替 Lab 留下一個最小結果契約。成功時回傳 SUCCESS 與資料;輸入錯誤 不該叫 Agent 重試,暫時性服務失敗則可以再試一次:

success({ id: "evt-1" });
failure("VALIDATION_ERROR", "BAD_INPUT");
failure("TEMPORARY_FAILURE", "API_UNAVAILABLE");

這三行看起來很樸素,卻比直接 throw new Error() 更容易接手。呼叫端不用從錯誤訊息猜測 下一步,測試也能明確驗證 retryabletrue 還是 false。後面把 Tool 接上正式 API 時,我們會沿用這個方向,把更多狀態補完整。

用 list → about → list 驗收完整生命週期

網站啟動後,依序切換:

  1. List route:畫面顯示 Active project Tools: get_event_details
  2. About route:active tools 變成 none
  3. 再回 List route:Tool 重新註冊,Register calls 應為 2

開發伺服器保持運作,再開一個終端機執行 focused tests:

npm test -- tests/unit/day-11-registry.test.ts tests/unit/day-11-result.test.ts
npx playwright test tests/browser/day-11-lifecycle.spec.ts

你也可以直接查看固定版本中的 lifecycle Labregistry unit testbrowser test

目前 main 另外提供較好找的讀者入口 labs/day-12-imperative-lifecycle/index.html?route=list,但 main 會繼續累積後續改動;本文的 操作與測試結果,仍以開頭切好的固定 tag 為準。

今天證明的是網站端生命週期,還不是 Agent 自主呼叫

這輪測試能證明 registry 的註冊、去重、解除與失敗恢復都可以穩定重播,證據等級是 E2。 它沒有證明真實瀏覽器 Agent 已經從自然語言選中 get_event_details,也不能把測試用的 register callback 當成正式 discovery 紀錄。

但地基至少整理乾淨了。昨天讓靜態表單能描述自己,今天則讓動態能力知道什麼時候該下班。 明天我們會打開 Inspector,不從下拉選單替 Agent 指定 Tool,只送一句自然語言搜尋。到時候 要看的不只是結果有沒有回來,還要確認模型究竟選了哪支 Tool;如果先收到的不是活動,而是 一個完全不同的錯誤,也會照實留下來。

參考資料