From 67faef01828d6b328ae724b772c20304575e6fa7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 17:49:59 +0100 Subject: [PATCH] perf(agent): skip plugin validation for gateway dispatch --- src/commands/agent-via-gateway.test.ts | 11 ++++++++++- src/commands/agent-via-gateway.ts | 24 +++++++++++++++++------ src/config/config.talk-validation.test.ts | 21 +++++++++++++++++++- src/config/io.ts | 19 +++++++++++++----- 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index dc456599def..77eb7cf2ce0 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -206,7 +206,7 @@ function createGatewayNormalCloseError() { }); } -vi.mock("../config/config.js", () => ({ getRuntimeConfig: loadConfig, loadConfig })); +vi.mock("../config/io.js", () => ({ getRuntimeConfig: loadConfig, loadConfig })); vi.mock("../gateway/call.js", () => ({ callGateway, isGatewayTransportError, @@ -284,6 +284,8 @@ describe("agentCliCommand", () => { expect(params.sessionKey).toBe("agent:main:incident-42"); expect(params.sessionId).toBeUndefined(); expect(params.to).toBeUndefined(); + expect(request.config).toBe(loadConfig.mock.results[0]?.value); + expect(loadConfig).toHaveBeenCalledWith({ skipPluginValidation: true, pin: false }); expect(agentCommand).not.toHaveBeenCalled(); expect(loadAgentSessionModuleMock).not.toHaveBeenCalled(); }); @@ -811,6 +813,7 @@ describe("agentCliCommand", () => { }); expect(fallbackAbort?.method).toBe("chat.abort"); expect(fallbackAbort?.timeoutMs).toBe(2_000); + expect(fallbackAbort?.config).toBe(loadConfig.mock.results[0]?.value); expect(fallbackAbort?.params).toEqual({ sessionKey: "agent:main:main", runId: "pre-accepted-run", @@ -960,6 +963,7 @@ describe("agentCliCommand", () => { expect(fallbackAbort?.clientName).toBe("gateway-client"); expect(fallbackAbort?.mode).toBe("backend"); expect(fallbackAbort?.scopes).toEqual(["operator.admin"]); + expect(fallbackAbort?.config).toBe(loadConfig.mock.results[0]?.value); expect(fallbackAbort?.params).toEqual({ sessionKey: "agent:main:main", runId: "run-model-fallback", @@ -1434,6 +1438,10 @@ describe("agentCliCommand", () => { }; expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/); expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`); + expect(loadConfig.mock.calls).toEqual([ + [{ skipPluginValidation: true, pin: false }], + [{ skipPluginValidation: true, pin: false }], + ]); }, { agents: { list: [{ id: "ops", default: true }, { id: "main" }] } }, ); @@ -1621,6 +1629,7 @@ describe("agentCliCommand", () => { ); expect(localOpts.agentId).toBe("ops"); expect(localOpts.sessionKey).toBe("agent:ops:incident-42"); + expect(loadConfig).toHaveBeenCalledWith(); }); }); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index ff9ba89f06d..ca3ac8628d5 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -4,11 +4,11 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../packages/gateway-protocol/src/client-info.js"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.types.js"; import { withProgress } from "../cli/progress.js"; -import { getRuntimeConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway, @@ -150,6 +150,12 @@ function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) { return raw; } +function getGatewayDispatchConfig(): OpenClawConfig { + // Scoped gateway turns need core agent/session/gateway fields only. The + // running gateway owns plugin validation and plugin metadata freshness. + return getRuntimeConfig({ skipPluginValidation: true, pin: false }); +} + function formatPayloadForLog(payload: { text?: string; mediaUrls?: string[]; @@ -231,7 +237,9 @@ function normalizeSessionKeyOptsForDispatch(opts: AgentCliOpts): AgentCliOpts { isLegacySessionKey && !agentIdRaw && !isUnscopedSessionKeySentinel(rawSessionKey); const cfg = isLegacySessionKey && (agentIdRaw || shouldScopeDefaultAgentKey) - ? getRuntimeConfig() + ? opts.local === true + ? getRuntimeConfig() + : getGatewayDispatchConfig() : undefined; const sessionKey = scopeLegacySessionKeyToAgent({ agentId: agentIdRaw ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg!) : undefined), @@ -401,6 +409,7 @@ async function abortAcceptedGatewayAgentRunWithGatewayCall(params: { signal: AgentCliSignal | undefined; runtime: RuntimeEnv; gatewayIdentity: AgentGatewayCallIdentity; + config: OpenClawConfig; }): Promise { const request: GatewayRequestFunction = async >( method: string, @@ -412,6 +421,7 @@ async function abortAcceptedGatewayAgentRunWithGatewayCall(params: { params: requestParams, timeoutMs: opts?.timeoutMs ?? undefined, expectFinal: opts?.expectFinal, + config: params.config, ...params.gatewayIdentity, }); for (const [attempt, retryDelayMs] of [...GATEWAY_ABORT_RETRY_DELAYS_MS, 0].entries()) { @@ -495,7 +505,7 @@ async function resolveAgentIdForGatewayTimeoutFallback( return resolveAgentIdFromSessionKey(explicitSessionKey); } if (isUnscopedSessionKeySentinel(explicitSessionKey)) { - return resolveDefaultAgentId(getRuntimeConfig()); + return resolveDefaultAgentId(getGatewayDispatchConfig()); } const agentIdRaw = opts.agent?.trim(); @@ -506,7 +516,7 @@ async function resolveAgentIdForGatewayTimeoutFallback( if (!opts.to && !opts.sessionId) { return undefined; } - const cfg = getRuntimeConfig(); + const cfg = getGatewayDispatchConfig(); const { resolveSessionKeyForRequest } = await loadAgentSessionModule(); const resolvedSessionKey = resolveSessionKeyForRequest({ cfg, @@ -558,7 +568,7 @@ async function agentViaGatewayCommand( ); } - const cfg = getRuntimeConfig(); + const cfg = getGatewayDispatchConfig(); const agentIdRaw = opts.agent?.trim(); const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined; if (agentId) { @@ -638,6 +648,7 @@ async function agentViaGatewayCommand( }, expectFinal: true, timeoutMs: gatewayTimeoutMs, + config: cfg, signal: signalBridge.signal, onAccepted: (payload) => { acceptedGatewayRun = true; @@ -670,6 +681,7 @@ async function agentViaGatewayCommand( signal: signalBridge.getReceivedSignal(), runtime, gatewayIdentity, + config: cfg, }); } throw err; diff --git a/src/config/config.talk-validation.test.ts b/src/config/config.talk-validation.test.ts index 9a351a15093..8cb52da9f81 100644 --- a/src/config/config.talk-validation.test.ts +++ b/src/config/config.talk-validation.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getRuntimeConfig, clearConfigCache, clearRuntimeConfigSnapshot } from "./config.js"; +import { + getRuntimeConfig, + clearConfigCache, + clearRuntimeConfigSnapshot, + getRuntimeConfigSnapshot, +} from "./config.js"; import { withTempHomeConfig } from "./test-helpers.js"; describe("talk config validation fail-closed behavior", () => { @@ -9,6 +14,20 @@ describe("talk config validation fail-closed behavior", () => { vi.restoreAllMocks(); }); + it("can load an unpinned runtime config without replacing the process snapshot", async () => { + await withTempHomeConfig({ gateway: { port: 19002 } }, async () => { + const unpinned = getRuntimeConfig({ skipPluginValidation: true, pin: false }); + + expect(unpinned.gateway?.port).toBe(19002); + expect(getRuntimeConfigSnapshot()).toBeNull(); + + const pinned = getRuntimeConfig(); + + expect(pinned.gateway?.port).toBe(19002); + expect(getRuntimeConfigSnapshot()).toBe(pinned); + }); + }); + async function expectInvalidTalkConfig(config: unknown, messagePattern: RegExp) { await withTempHomeConfig(config, async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/config/io.ts b/src/config/io.ts index 15676049c2f..457df7bbfe4 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -2540,16 +2540,25 @@ export function projectConfigOntoRuntimeSourceSnapshot(config: OpenClawConfig): return coerceConfig(applyMergePatch(projectedSource, runtimePatch)); } -export function loadConfig(options?: { skipPluginValidation?: boolean }): OpenClawConfig { +export function loadConfig(options?: { + skipPluginValidation?: boolean; + pin?: boolean; +}): OpenClawConfig { + const loadFresh = () => + createConfigIO(options?.skipPluginValidation ? { pluginValidation: "skip" } : {}).loadConfig(); + if (options?.pin === false) { + return loadFresh(); + } // First successful load becomes the process snapshot. Long-lived runtimes // should swap this snapshot via explicit reload/watcher paths instead of // reparsing openclaw.json on hot code paths. - return loadPinnedRuntimeConfig(() => - createConfigIO(options?.skipPluginValidation ? { pluginValidation: "skip" } : {}).loadConfig(), - ); + return loadPinnedRuntimeConfig(loadFresh); } -export function getRuntimeConfig(options?: { skipPluginValidation?: boolean }): OpenClawConfig { +export function getRuntimeConfig(options?: { + skipPluginValidation?: boolean; + pin?: boolean; +}): OpenClawConfig { return loadConfig(options); }