Skip to content

RPC 方法表與請求分派

源码版本v2026.6.11

職責

server-methods.ts 是閘道 (gateway) 的 RPC 中樞:把進來的 JSON-RPC 請求按方法名分派到 handler,並負責授權 (authorization)、限流和請求作用域隔離。真正幹活的 handler 分散在 server-methods/ 下按家族拆分的子模組——chat、agents、cron、channels、device、artifacts、connect 等。

這一層做四件事:把核心方法聚合成表(coreGatewayHandlers),每請求臨時合併出 registry,授權後交給 handler,最後把 handler 跑進外掛請求作用域 (plugin runtime request scope),讓 handler 內部 spawn 的 subagent 還能回呼閘道方法。

設計動機

為什麼不直接寫一個 switch(method) 大表?因為閘道要支援外掛 (plugin) 熱註冊方法、要支援測試覆蓋核心方法、還要在啟動早期就 advertise 方法表(很多 handler 模組此時沒載入)。三個約束合在一起,就要求方法表是宣告優先、載入延後、每請求合併

宣告優先意味著 coreGatewayHandlers 只是 {方法名: handler} 映射,handler 用 createLazyCoreHandlers 包動態 import——首次呼叫方法時才載入模組,後續呼叫複用同一個 import promise(lazyHandlerModulehandlersPromise ??= 快取),避免並發觸發重複載入。每請求合併意味著 createRequestGatewayMethodRegistry 在請求到達時,從全域外掛狀態取當前啟用 plugin handlers,和核心表、呼叫方 extra handlers 合併——這樣外掛熱註冊的方法對正在跑的請求立即可見,不需要重啟。

關鍵檔案

資料流

核心方法表是一個巨大的物件字面量(coreGatewayHandlers:269),按家族聚類,每個家族用 createLazyCoreHandlers 包一層:

typescript
export const coreGatewayHandlers: GatewayRequestHandlers = {
  ...createLazyCoreHandlers({
    methods: ["chat.history", "chat.startup", "chat.metadata",
             "chat.message.get", "chat.abort", "chat.send", "chat.inject"],
    loadHandlers: loadChatHandlers,
  }),
  ...createLazyCoreHandlers({
    methods: ["wake", "cron.list", "cron.status", "cron.get", "cron.add",
             "cron.update", "cron.remove", "cron.run", "cron.runs"],
    loadHandlers: loadCronHandlers,
  }),
  // ...channels/device/agents/artifacts/sessions...
};

可以看到方法命名是點分層級:chat.* 聊天工作階段、cron.* 定時任務、channels.* 通道啟停、device.pair.* 設備配對。wake 是單數頂級的喚醒方法,不在家族前綴下。

createLazyCoreHandlers(createLazyCoreHandlers:44)給每個方法名生成 wrapper,首次呼叫才觸發 loadHandlers(),快取到閉包。關鍵點是 descriptor drift 必拋錯:

typescript
async (opts: GatewayRequestHandlerOptions) => {
  const handlers = await params.loadHandlers();
  const handler = handlers[method];
  if (!handler) {
    // Descriptor drift should fail loudly: advertised core methods must exist in the
    // loaded family module once the lazy boundary resolves.
    throw new Error(`lazy gateway handler not found: ${method}`);
  }
  await handler(opts);
}

宣告了方法名但載入出來的家族模組沒對應 handler,是設定錯位,必須拋錯,不能默默返回 unknown method。

請求到達時,handleGatewayRequest(handleGatewayRequest:638)先決定用哪份 registry,再做授權,最後跑 handler:

typescript
// When the attached snapshot does not own the method, rebuild from the live plugin registry
// so plugin RPC methods registered after the startup snapshot stay reachable (#94127).
const methodRegistry =
  opts.methodRegistry?.getHandler(req.method) !== undefined
    ? opts.methodRegistry
    : createRequestGatewayMethodRegistry(opts.extraHandlers);
const authError = authorizeGatewayMethod(req.method, client, req.params, methodRegistry);
if (authError) {
  respond(false, undefined, authError);
  return;
}

這段修了 #94127:啟動時拍過方法快照,但之後外掛熱註冊的新方法在快照裡沒有。解法是先看快照持不持有該方法,不持有就回退到 createRequestGatewayMethodRegistry 從 live plugin state 重建——重建廉價,只在快照 miss 時發生。

授權走兩層:先看 role(operator / node / admin),node 角色和持 ADMIN_SCOPE 的連線直接放行;其他角色再查 method 註冊的 scope,用 authorizeOperatorScopesForMethodauthorizeOperatorScopesForRequiredScope 校驗用戶端 scopes 是否覆蓋。

通過授權後,如果是控制面寫操作 (isControlPlaneWrite),先過限流(control-plane rate limit:669),預設配額「每 60 秒 3 次」,超額返回 UNAVAILABLE + retryAfterMs。限流放在 handler lookup 之前,讓外掛和 aux 註冊的寫方法也吃同一道關。

最後 handler 在外掛請求作用域裡執行(withPluginRuntimeGatewayRequestScope:706):withPluginRuntimeGatewayRequestScope({ context, client, isWebchatConnect }, invokeHandler) 包住 handler({ req, params, client, isWebchatConnect, respond, context })。作用域的作用是讓 handler 內部 spawn subagent、subagent 又呼叫閘道方法(比如 tool 執行時回呼 chat.send)時,巢狀呼叫能繼承呼叫方身份 (caller identity),外掛註冊的 RPC 方法也能拿到當前 client 上下文。沒有作用域,巢狀呼叫會丟身份、丟 isWebchatConnect 標記,導致下游授權判斷錯誤。

邊界與失敗

  • descriptor drift 必拋:家族模組載入完卻找不到宣告方法時,直接 throw new Error。設定錯位不能靜默吞掉,否則用戶端收 unknown method 但伺服端日誌無異常。
  • startup 階段方法可達性:啟動早期方法表已 advertise,但 handler 模組未必載入完。handleGatewayRequest 檢查 context.unavailableGatewayMethods,命中返回 UNAVAILABLE + retryable: true + retryAfterMs: GATEWAY_STARTUP_RETRY_AFTER_MS,讓用戶端按協定退避(startup unavailable:655)。
  • #94127 熱註冊 handler 可達性:methodRegistry 選擇邏輯——快照持方法用快照,否則從 live plugin registry 重建。改壞會讓外掛方法在啟動後註冊的都看不見。
  • 外掛 handler 優先級:createRequestGatewayMethodRegistry 裡外掛 handler 永遠贏過 extra handler(if (!pluginMethodNames.has(method))),防止呼叫方 extra handler 影子化已載入外掛方法——外掛是使用者顯式裝進來的,優先級高於 harness-local 注入。
  • 控制面限流位置:限流在 handler lookup 之前,不在之後。放在 handler 內部會讓外掛和 aux 寫方法繞過關。
  • 作用域失敗:withPluginRuntimeGatewayRequestScope 拋錯會讓請求 fail,但 handler 已執行一半的狀態怎麼回滾由 handler 自己負責,作用域不提供事務性。

小結

方法表宣告優先:coreGatewayHandlers 一張表說清有哪些方法、按家族聚類;載入延後:createLazyCoreHandlers 把動態 import 包成快取 wrapper;每請求合併:createRequestGatewayMethodRegistry 把核心、外掛、extra 三層合到一起,外掛優先。授權 (authorization) 走 role + scope 兩層,控制面寫操作額外過限流,handler 跑在外掛請求作用域裡支援巢狀回呼。

請求怎麼進到這層、廣播原語怎麼注入,看 閘道核心;handler 執行後 agent 事件怎麼投遞回用戶端,看 聊天廣播與事件投遞;handler 內部如何驅動 agent 主循環,看 嵌入式 Runner

對照官方資料:Gateway 文件 · README