fix(plugins): retain plugin tool registry after replacement (#82562)

Merged via squash.

Prepared head SHA: 1bcbbbfbc1
Co-authored-by: luoyanglang <238804951+luoyanglang@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
狼哥
2026-06-24 07:22:29 -07:00
committed by GitHub
parent 242fbf1a67
commit 374076b5a8
5 changed files with 127 additions and 21 deletions

View File

@@ -21,6 +21,7 @@ const metadataSnapshot = {
workspaceDir: "/resolved-workspace",
};
const loadPluginMetadataSnapshotMock = vi.fn(() => metadataSnapshot);
const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true);
const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined);
const setCurrentPluginMetadataSnapshotMock = vi.fn();
const clearCurrentPluginMetadataSnapshotMock = vi.fn();
@@ -45,6 +46,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
}));
vi.mock("../plugin-metadata-snapshot.js", () => ({
isPluginMetadataSnapshotCompatible: isPluginMetadataSnapshotCompatibleMock,
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
@@ -69,6 +71,8 @@ describe("resolvePluginRuntimeLoadContext", () => {
applyPluginAutoEnableMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
isPluginMetadataSnapshotCompatibleMock.mockReset();
isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true);
loadPluginMetadataSnapshotMock.mockClear();
getCurrentPluginMetadataSnapshotMock.mockClear();
setCurrentPluginMetadataSnapshotMock.mockClear();

View File

@@ -14,7 +14,10 @@ import {
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js";
import type { PluginLoadOptions } from "../loader.js";
import type { PluginManifestRegistry } from "../manifest-registry.js";
import { resolvePluginMetadataSnapshot } from "../plugin-metadata-snapshot.js";
import {
isPluginMetadataSnapshotCompatible,
resolvePluginMetadataSnapshot,
} from "../plugin-metadata-snapshot.js";
import type { PluginLogger } from "../types.js";
const log = createSubsystemLogger("plugins");
@@ -73,18 +76,16 @@ export function resolvePluginRuntimeLoadContext(
const rawConfig = options?.config ?? getRuntimeConfig();
const rawWorkspaceDir =
options?.workspaceDir ?? resolveAgentWorkspaceDir(rawConfig, resolveDefaultAgentId(rawConfig));
const metadataSnapshot = options?.manifestRegistry
? undefined
: resolvePluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
allowWorkspaceScopedCurrent: true,
});
const manifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry;
const installRecords = metadataSnapshot
? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index)
: undefined;
const initialMetadataSnapshot =
options?.manifestRegistry === undefined
? resolvePluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
allowWorkspaceScopedCurrent: true,
})
: undefined;
const manifestRegistry = options?.manifestRegistry ?? initialMetadataSnapshot?.manifestRegistry;
const activationSourceConfig = resolvePluginActivationSourceConfig({
config: rawConfig,
activationSourceConfig: options?.activationSourceConfig,
@@ -93,11 +94,33 @@ export function resolvePluginRuntimeLoadContext(
config: rawConfig,
env,
manifestRegistry,
discovery: metadataSnapshot?.discovery,
discovery: initialMetadataSnapshot?.discovery,
});
const config = autoEnabled.config;
const workspaceDir =
options?.workspaceDir ?? resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const metadataSnapshot =
options?.manifestRegistry !== undefined
? undefined
: initialMetadataSnapshot &&
isPluginMetadataSnapshotCompatible({
snapshot: initialMetadataSnapshot,
config,
env,
workspaceDir,
})
? initialMetadataSnapshot
: resolvePluginMetadataSnapshot({
config,
env,
workspaceDir,
allowWorkspaceScopedCurrent: true,
...(initialMetadataSnapshot ? { index: initialMetadataSnapshot.index } : {}),
});
const finalManifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry;
const installRecords = metadataSnapshot
? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index)
: undefined;
if (metadataSnapshot) {
// Reusable snapshots stay available to later manifest-policy lookups for this runtime load.
if (isReusableCurrentPluginMetadataSnapshot(metadataSnapshot)) {
@@ -119,7 +142,7 @@ export function resolvePluginRuntimeLoadContext(
workspaceDir,
env,
logger: options?.logger ?? createPluginRuntimeLoaderLogger(),
manifestRegistry,
...(finalManifestRegistry ? { manifestRegistry: finalManifestRegistry } : {}),
installRecords,
};
}

View File

@@ -27,7 +27,7 @@ function resolveRuntimeSubagentMode(
return "default";
}
function installStandaloneRegistry(
function installStandaloneRuntimePluginRegistry(
registry: PluginRegistry,
params: {
loadOptions: PluginLoadOptions;
@@ -99,7 +99,7 @@ export function ensureStandaloneRuntimePluginRegistryLoaded(params: {
return registry;
}
installStandaloneRegistry(registry, {
installStandaloneRuntimePluginRegistry(registry, {
loadOptions: params.loadOptions,
surface,
});

View File

@@ -2147,6 +2147,62 @@ describe("resolvePluginTools optional tools", () => {
expect(factory).toHaveBeenCalledTimes(2);
});
it("retains cold-loaded plugin tools for cached descriptor execution after active registry replacement", async () => {
const factory = vi.fn(() => makeTool("cached_lifecycle_tool"));
const gatewayRegistry = setRegistry([
{
pluginId: "cache-lifecycle-test",
optional: false,
source: "/tmp/cache-lifecycle-test.js",
names: ["cached_lifecycle_tool"],
factory,
},
]);
const first = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["cached_lifecycle_tool"],
allowGatewaySubagentBinding: true,
}),
);
const [tool] = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["cached_lifecycle_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(first, ["cached_lifecycle_tool"]);
expect(tool?.name).toBe("cached_lifecycle_tool");
expect(factory).toHaveBeenCalledTimes(1);
const unrelatedEntry: MockRegistryToolEntry = {
pluginId: "unrelated-live",
optional: false,
source: "/tmp/unrelated-live.js",
names: ["unrelated_live_tool"],
factory: () => makeTool("unrelated_live_tool"),
};
const replacementRegistry = createToolRegistry([unrelatedEntry]);
replacementRegistry.plugins.push({ id: "cache-lifecycle-test", status: "loaded" });
setActivePluginRegistry?.(replacementRegistry as never, "provider-runtime", "default", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock
.mockReturnValueOnce(gatewayRegistry)
.mockReturnValue(createToolRegistry([]));
await expect(tool?.execute("call-1", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "ok" }],
});
await expect(tool?.execute("call-2", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "ok" }],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1);
expect(getActivePluginRegistry?.()).toBe(replacementRegistry);
expect(getActivePluginRegistry?.()?.tools.map((entry) => entry.pluginId)).toContain(
"unrelated-live",
);
});
it("does not reuse cached plugin tool descriptors across sandbox context changes", () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sandboxed?: boolean };

View File

@@ -31,16 +31,21 @@ import {
capturePluginToolDescriptor,
createPluginToolDescriptorConfigCacheKeyMemo,
readCachedPluginToolDescriptors,
resetPluginToolDescriptorCache as resetCachedPluginToolDescriptors,
type CachedPluginToolDescriptor,
type PluginToolDescriptorConfigCacheKeyMemo,
writeCachedPluginToolDescriptors,
} from "./tool-descriptor-cache.js";
import type { OpenClawPluginToolContext } from "./types.js";
export {
resetPluginToolDescriptorCache,
resetPluginToolDescriptorCache as resetPluginToolFactoryCache,
} from "./tool-descriptor-cache.js";
let cachedDescriptorRuntimeRegistries = new WeakMap<CachedPluginToolDescriptor, PluginRegistry>();
export function resetPluginToolDescriptorCache(): void {
resetCachedPluginToolDescriptors();
cachedDescriptorRuntimeRegistries = new WeakMap();
}
export { resetPluginToolDescriptorCache as resetPluginToolFactoryCache };
/** MCP bridge metadata attached to plugin tools surfaced through agent tool lists. */
export type PluginToolMcpMeta = {
@@ -692,6 +697,10 @@ function createCachedDescriptorPluginTool(params: {
const registry = resolvePluginToolRegistry({
loadOptions,
onlyPluginIds: [pluginId],
retainedRegistry: cachedDescriptorRuntimeRegistries.get(params.descriptor),
onRetainRegistry: (retainedRegistry) => {
cachedDescriptorRuntimeRegistries.set(params.descriptor, retainedRegistry);
},
});
const candidates = registry?.tools.filter((candidate) => candidate.pluginId === pluginId);
if (!candidates || candidates.length === 0) {
@@ -899,6 +908,8 @@ function resolveCachedPluginTools(params: {
function resolvePluginToolRegistry(params: {
loadOptions: PluginLoadOptions;
onlyPluginIds?: readonly string[];
retainedRegistry?: PluginRegistry;
onRetainRegistry?: (registry: PluginRegistry) => void;
}) {
const lookup = {
env: params.loadOptions.env,
@@ -924,7 +935,16 @@ function resolvePluginToolRegistry(params: {
return activeRegistry;
}
if (registryHasScopedPluginTools(params.retainedRegistry, params.onlyPluginIds)) {
return params.retainedRegistry;
}
const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry);
const shouldRetainColdLoadedToolRegistry =
forceStandaloneLoad &&
params.loadOptions.activate === false &&
params.loadOptions.toolDiscovery === true &&
params.onRetainRegistry !== undefined;
const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({
surface: "active",
forceLoad: forceStandaloneLoad,
@@ -933,6 +953,9 @@ function resolvePluginToolRegistry(params: {
loadOptions: params.loadOptions,
});
if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) {
if (shouldRetainColdLoadedToolRegistry) {
params.onRetainRegistry?.(standaloneRegistry);
}
return standaloneRegistry;
}
return standaloneRegistry ?? channelRegistry ?? activeRegistry;