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。