perf(agents): reuse gateway plugin preparation (#105646)

* perf(agents): reuse gateway plugin preparation

* chore: keep release notes out of PR
This commit is contained in:
Peter Steinberger
2026-07-12 12:59:26 -07:00
committed by GitHub
parent e3c338f664
commit 1b281de6df
12 changed files with 203 additions and 9 deletions

View File

@@ -1,2 +1,2 @@
8f362012108b8b2db08e836883fac095f88e70a4c1ef66a713980e1361ec48b6 plugin-sdk-api-baseline.json
ddbb02207930b419658209039696e8008d76c8f839d7ecde95e1c8ba91c19c7a plugin-sdk-api-baseline.jsonl
0eb8e2e8ebfe48bbf269d6e0100e38b8c85c387651be7a3bc3d67f549a13768f plugin-sdk-api-baseline.json
f6e5dc7474a7cb54bdcde544f135656ac8e501679e18129607f4f42fec7cd791 plugin-sdk-api-baseline.jsonl

View File

@@ -120,6 +120,23 @@ describe("bundle LSP runtime", () => {
loadEmbeddedAgentLspConfigMock.mockReset();
});
it("reuses the prepared plugin manifest registry for bundle discovery", async () => {
loadEmbeddedAgentLspConfigMock.mockReturnValue({ lspServers: {}, diagnostics: [] });
const manifestRegistry = { plugins: [] };
const { createBundleLspToolRuntime } = await import("./agent-bundle-lsp-runtime.js");
await createBundleLspToolRuntime({
workspaceDir: "/tmp/workspace",
manifestRegistry,
});
expect(loadEmbeddedAgentLspConfigMock).toHaveBeenCalledWith({
workspaceDir: "/tmp/workspace",
cfg: undefined,
manifestRegistry,
});
});
it("starts LSP servers in a disposable process group", async () => {
configureSingleLspServer();
const child = new MockChildProcess();

View File

@@ -9,6 +9,7 @@ import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { setPluginToolMeta } from "../plugins/tools.js";
import { killProcessTree } from "../process/kill-tree.js";
import { loadEmbeddedAgentLspConfig } from "./embedded-agent-lsp.js";
@@ -499,10 +500,12 @@ export async function createBundleLspToolRuntime(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
reservedToolNames?: Iterable<string>;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}): Promise<BundleLspToolRuntime> {
const loaded = loadEmbeddedAgentLspConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry,
});
for (const diagnostic of loaded.diagnostics) {
logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`);

View File

@@ -1700,8 +1700,10 @@ process.on("SIGINT", shutdown);`,
it("reuses repeated materialization and recreates after explicit disposal", async () => {
const created: SessionMcpRuntime[] = [];
const createdManifestRegistries: unknown[] = [];
const disposed: string[] = [];
const createRuntime: RuntimeFactory = (params) => {
createdManifestRegistries.push(params.manifestRegistry);
const runtime = makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]);
created.push(runtime);
return {
@@ -1716,16 +1718,19 @@ process.on("SIGINT", shutdown);`,
};
};
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
const manifestRegistry = { plugins: [] };
const runtimeA = await manager.getOrCreate({
sessionId: "session-a",
sessionKey: "agent:test:session-a",
workspaceDir: "/workspace",
manifestRegistry,
});
const runtimeB = await manager.getOrCreate({
sessionId: "session-a",
sessionKey: "agent:test:session-a",
workspaceDir: "/workspace",
manifestRegistry,
});
const materializedA = await materializeBundleMcpToolsForRun({ runtime: runtimeA });
@@ -1738,6 +1743,7 @@ process.on("SIGINT", shutdown);`,
expect(materializedA.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]);
expect(materializedB.tools.map((tool) => tool.name)).toEqual(["bundleProbe__bundle_probe"]);
expect(created).toHaveLength(1);
expect(createdManifestRegistries).toEqual([manifestRegistry]);
expect(manager.listSessionIds()).toEqual(["session-a"]);
await manager.disposeSession("session-a");
@@ -1747,11 +1753,13 @@ process.on("SIGINT", shutdown);`,
sessionId: "session-a",
sessionKey: "agent:test:session-a",
workspaceDir: "/workspace",
manifestRegistry,
});
await materializeBundleMcpToolsForRun({ runtime: runtimeC });
expect(runtimeC).not.toBe(runtimeA);
expect(created).toHaveLength(2);
expect(createdManifestRegistries).toEqual([manifestRegistry, manifestRegistry]);
const materializedC = await materializeBundleMcpToolsForRun({
runtime: runtimeC,

View File

@@ -1077,6 +1077,7 @@ function createSessionMcpRuntimeManager(
workspaceDir: params.workspaceDir,
cfg: params.cfg,
logDiagnostics: false,
manifestRegistry: params.manifestRegistry,
});
const existing = runtimesBySessionId.get(params.sessionId);
if (existing) {
@@ -1120,6 +1121,7 @@ function createSessionMcpRuntimeManager(
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry,
configFingerprint: nextFingerprint,
}),
).then((runtime) => {
@@ -1212,6 +1214,7 @@ export async function getOrCreateSessionMcpRuntime(params: {
workspaceDir: string;
agentDir?: string;
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}): Promise<SessionMcpRuntime> {
return await getSessionMcpRuntimeManager().getOrCreate(params);
}

View File

@@ -6,6 +6,7 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import type { TSchema } from "typebox";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { AnyAgentTool } from "./tools/common.js";
/** Materialized MCP tools plus diagnostics and cleanup handle for one run. */
@@ -113,6 +114,7 @@ export type SessionMcpRuntimeManager = {
workspaceDir: string;
agentDir?: string;
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}) => Promise<SessionMcpRuntime>;
bindSessionKey: (sessionKey: string, sessionId: string) => void;
resolveSessionId: (sessionKey: string) => string | undefined;

View File

@@ -4,6 +4,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { BundleLspServerConfig } from "../plugins/bundle-lsp.js";
import { loadEnabledBundleLspConfig } from "../plugins/bundle-lsp.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
type EmbeddedAgentLspConfig = {
lspServers: Record<string, BundleLspServerConfig>;
@@ -14,10 +15,12 @@ type EmbeddedAgentLspConfig = {
export function loadEmbeddedAgentLspConfig(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}): EmbeddedAgentLspConfig {
const bundleLsp = loadEnabledBundleLspConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry,
});
// User-configured LSP servers could override bundle defaults here in the future.
return {

View File

@@ -1919,6 +1919,13 @@ export async function runEmbeddedAttempt(
disableTools: params.disableTools || isRawModelRun,
toolsAllow: params.toolsAllow,
});
const bundleMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot();
// Scoped registries are partial views. Bundle discovery can skip its own scan only when
// the attempt snapshot covers every plugin; otherwise MCP/LSP bundles can disappear.
const bundleManifestRegistry =
bundleMetadataSnapshot?.pluginIds === undefined
? bundleMetadataSnapshot?.manifestRegistry
: undefined;
const bundleMcpSessionRuntime = bundleMcpEnabled
? await getOrCreateSessionMcpRuntime({
sessionId: params.sessionId,
@@ -1926,6 +1933,7 @@ export async function runEmbeddedAttempt(
workspaceDir: effectiveWorkspace,
agentDir,
cfg: params.config,
manifestRegistry: bundleManifestRegistry,
})
: undefined;
bundleMcpRuntime = bundleMcpSessionRuntime
@@ -1948,6 +1956,7 @@ export async function runEmbeddedAttempt(
? await createBundleLspToolRuntime({
workspaceDir: effectiveWorkspace,
cfg: params.config,
manifestRegistry: bundleManifestRegistry,
reservedToolNames: [
...tools.map((tool) => tool.name),
...(clientTools?.map((tool) => tool.function.name) ?? []),

View File

@@ -69,6 +69,16 @@ function stableFingerprintValue(value: unknown): string {
.join(",")}}`;
}
/** Fingerprints mutable config inputs used by plugin auto-enable detection. */
export function fingerprintPluginAutoEnableConfig(config: OpenClawConfig): string {
return hashRuntimeConfigValue(config);
}
/** Fingerprints mutable environment inputs used by plugin auto-enable detection. */
export function fingerprintPluginAutoEnableEnv(env: NodeJS.ProcessEnv): string {
return stableFingerprintValue(env);
}
function createPluginAutoEnableCacheEntry(params: {
config: OpenClawConfig;
discovery: PluginDiscoveryResult;
@@ -77,9 +87,9 @@ function createPluginAutoEnableCacheEntry(params: {
result: PluginAutoEnableResult;
}): PluginAutoEnableCacheEntry {
return {
configFingerprint: hashRuntimeConfigValue(params.config),
configFingerprint: fingerprintPluginAutoEnableConfig(params.config),
discoveryFingerprint: stableFingerprintValue(params.discovery.candidates),
envFingerprint: stableFingerprintValue(params.env),
envFingerprint: fingerprintPluginAutoEnableEnv(params.env),
registryFingerprint: stableFingerprintValue(params.manifestRegistry.plugins),
result: params.result,
};
@@ -93,9 +103,9 @@ function isPluginAutoEnableCacheEntryFresh(params: {
manifestRegistry: PluginManifestRegistry;
}): boolean {
return (
params.entry.configFingerprint === hashRuntimeConfigValue(params.config) &&
params.entry.configFingerprint === fingerprintPluginAutoEnableConfig(params.config) &&
params.entry.discoveryFingerprint === stableFingerprintValue(params.discovery.candidates) &&
params.entry.envFingerprint === stableFingerprintValue(params.env) &&
params.entry.envFingerprint === fingerprintPluginAutoEnableEnv(params.env) &&
params.entry.registryFingerprint === stableFingerprintValue(params.manifestRegistry.plugins)
);
}

View File

@@ -15,6 +15,7 @@ import {
mergeBundlePathLists,
normalizeBundlePathList,
} from "./bundle-manifest.js";
import type { PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginBundleFormat } from "./manifest-types.js";
/** LSP server config block loaded from plugin bundle metadata. */
@@ -150,10 +151,12 @@ export function inspectBundleLspRuntimeSupport(params: {
export function loadEnabledBundleLspConfig(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
manifestRegistry?: Pick<PluginManifestRegistry, "plugins">;
}): { config: BundleLspConfig; diagnostics: Array<{ pluginId: string; message: string }> } {
return loadEnabledBundleConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry,
createEmptyConfig: () => ({ lspServers: {} }),
loadBundleConfig: loadBundleLspConfig,
createDiagnostic: (pluginId, message) => ({ pluginId, message }),

View File

@@ -1,5 +1,6 @@
// Load context tests cover agent and workspace context resolution for plugin runtimes.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
const loadConfigMock = vi.fn<typeof import("../../config/config.js").loadConfig>();
const applyPluginAutoEnableMock =
@@ -20,7 +21,8 @@ const metadataSnapshot = {
policyHash: "policy",
workspaceDir: "/resolved-workspace",
};
const loadPluginMetadataSnapshotMock = vi.fn(() => metadataSnapshot);
type MetadataSnapshotMock = typeof metadataSnapshot & { pluginIds?: readonly string[] };
const loadPluginMetadataSnapshotMock = vi.fn((): MetadataSnapshotMock => metadataSnapshot);
const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true);
const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined);
const setCurrentPluginMetadataSnapshotMock = vi.fn();
@@ -190,6 +192,50 @@ describe("resolvePluginRuntimeLoadContext", () => {
});
});
it("reuses auto-enable results until Gateway config or metadata changes", () => {
const rawConfig = { plugins: {} };
const env = process.env;
const initialSnapshot = { ...metadataSnapshot, pluginIds: ["openai"] };
loadPluginMetadataSnapshotMock
.mockReturnValueOnce(initialSnapshot)
.mockReturnValueOnce({ ...initialSnapshot, pluginIds: ["openai"] })
.mockReturnValueOnce({ ...initialSnapshot, policyHash: "changed" })
.mockReturnValueOnce(initialSnapshot);
const first = resolvePluginRuntimeLoadContext({ config: rawConfig, env });
const second = resolvePluginRuntimeLoadContext({ config: rawConfig, env });
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
resolvePluginRuntimeLoadContext({ config: { plugins: {} }, env });
expect(second.config).toBe(first.config);
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(3);
});
it("invalidates auto-enable results when config or process env mutates in place", () => {
const rawConfig: OpenClawConfig = { plugins: {} };
const env = process.env;
const envKey = "OPENCLAW_TEST_PLUGIN_AUTO_ENABLE_FINGERPRINT";
const previousEnvValue = env[envKey];
delete env[envKey];
try {
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
rawConfig.plugins = { entries: { demo: { enabled: true } } };
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
env[envKey] = "changed";
resolvePluginRuntimeLoadContext({ config: rawConfig, env });
expect(applyPluginAutoEnableMock).toHaveBeenCalledTimes(3);
} finally {
if (previousEnvValue === undefined) {
delete env[envKey];
} else {
env[envKey] = previousEnvValue;
}
}
});
it("threads install records from the metadata snapshot into the context and load options", () => {
const snapshotWithRecords = {
...metadataSnapshot,

View File

@@ -1,6 +1,10 @@
// Plugin runtime load context helpers resolve agent and workspace facts for runtime activation.
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getRuntimeConfig } from "../../config/config.js";
import {
fingerprintPluginAutoEnableConfig,
fingerprintPluginAutoEnableEnv,
} from "../../config/plugin-auto-enable.apply.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
@@ -14,6 +18,7 @@ import {
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js";
import type { PluginLoadOptions } from "../loader.js";
import type { PluginManifestRegistry } from "../manifest-registry.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugin-metadata-lifecycle.js";
import {
isPluginMetadataSnapshotCompatible,
resolvePluginMetadataSnapshot,
@@ -22,6 +27,90 @@ import type { PluginLogger } from "../types.js";
const log = createSubsystemLogger("plugins");
type CurrentAutoEnableCacheEntry = {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
autoEnableConfigFingerprint: string;
autoEnableEnvFingerprint: string;
metadataConfigFingerprint: string | undefined;
pluginIds: readonly string[] | undefined;
policyHash: string;
result: ReturnType<typeof applyPluginAutoEnable>;
workspaceDir: string | undefined;
};
let currentAutoEnableCache: CurrentAutoEnableCacheEntry | undefined;
registerPluginMetadataProcessMemoLifecycleClear(() => {
currentAutoEnableCache = undefined;
});
function samePluginIds(
left: readonly string[] | undefined,
right: readonly string[] | undefined,
): boolean {
return (
left === right ||
(left !== undefined &&
right !== undefined &&
left.length === right.length &&
left.every((pluginId, index) => pluginId === right[index]))
);
}
function applyCurrentPluginAutoEnable(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
manifestRegistry: PluginManifestRegistry | undefined;
snapshot: ReturnType<typeof resolvePluginMetadataSnapshot> | undefined;
}): ReturnType<typeof applyPluginAutoEnable> {
if (!params.snapshot || !params.manifestRegistry || params.env !== process.env) {
return applyPluginAutoEnable({
config: params.config,
env: params.env,
manifestRegistry: params.manifestRegistry,
discovery: params.snapshot?.discovery,
});
}
// Gateway plugin metadata and config are replacement snapshots. Reuse only while
// mutable config/env content still matches; reload/close lifecycle clears the slot.
const workspaceDir = params.snapshot.workspaceDir ?? params.workspaceDir;
const autoEnableConfigFingerprint = fingerprintPluginAutoEnableConfig(params.config);
const autoEnableEnvFingerprint = fingerprintPluginAutoEnableEnv(params.env);
const cached = currentAutoEnableCache;
if (
cached?.config === params.config &&
cached.env === params.env &&
cached.autoEnableConfigFingerprint === autoEnableConfigFingerprint &&
cached.autoEnableEnvFingerprint === autoEnableEnvFingerprint &&
cached.metadataConfigFingerprint === params.snapshot.configFingerprint &&
cached.policyHash === params.snapshot.policyHash &&
cached.workspaceDir === workspaceDir &&
samePluginIds(cached.pluginIds, params.snapshot.pluginIds)
) {
return cached.result;
}
const result = applyPluginAutoEnable({
config: params.config,
env: params.env,
manifestRegistry: params.manifestRegistry,
discovery: params.snapshot.discovery,
});
currentAutoEnableCache = {
config: params.config,
env: params.env,
autoEnableConfigFingerprint,
autoEnableEnvFingerprint,
metadataConfigFingerprint: params.snapshot.configFingerprint,
pluginIds: params.snapshot.pluginIds,
policyHash: params.snapshot.policyHash,
result,
workspaceDir,
};
return result;
}
/** Resolved plugin runtime load context shared by runtime loader callers. */
export type PluginRuntimeLoadContext = {
rawConfig: OpenClawConfig;
@@ -90,11 +179,12 @@ export function resolvePluginRuntimeLoadContext(
config: rawConfig,
activationSourceConfig: options?.activationSourceConfig,
});
const autoEnabled = applyPluginAutoEnable({
const autoEnabled = applyCurrentPluginAutoEnable({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
manifestRegistry,
discovery: initialMetadataSnapshot?.discovery,
snapshot: initialMetadataSnapshot,
});
const config = autoEnabled.config;
const workspaceDir =