RPC 方法表與請求分派
職責
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(lazyHandlerModule 裡 handlersPromise ??= 快取),避免並發觸發重複載入。每請求合併意味著 createRequestGatewayMethodRegistry 在請求到達時,從全域外掛狀態取當前啟用 plugin handlers,和核心表、呼叫方 extra handlers 合併——這樣外掛熱註冊的方法對正在跑的請求立即可見,不需要重啟。
關鍵檔案
lazyHandlerModule:34-42— 快取首次 import promise,避免並發觸發重複載入。createLazyCoreHandlers:44-63— 把方法名包成懶載入 wrapper,descriptor drift 直接拋錯。coreGatewayHandlers:269-595— 核心 RPC handler 註冊表,按家族分組。authorizeGatewayMethod:240-267— role + scope 校驗,ADMIN_SCOPE 直通。createRequestGatewayMethodRegistry:597-635— 每請求合併 core+plugin+extra,plugin 優先於 extra。handleGatewayRequest:637-720— 授權、startup unavailable、控制面限流、分派。lazy loader 家族:65-100— chat/cron/channels/device 等模組的lazyHandlerModule宣告。
資料流
核心方法表是一個巨大的物件字面量(coreGatewayHandlers:269),按家族聚類,每個家族用 createLazyCoreHandlers 包一層:
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 必拋錯:
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:
// 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,用 authorizeOperatorScopesForMethod 或 authorizeOperatorScopesForRequiredScope 校驗用戶端 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。