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