mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: stateful plugin hooks and tools stay aligned after scoped loads (#108110)
* fix(plugins): align hook and tool registrations Reuse gateway-owned plugin registrations for matching hooks and tools while loading only missing tool owners from narrower runtime scopes.\n\nCo-authored-by: w33d <w33d@steadholme.local> * test(plugins): cover mixed registry tool owners Verify gateway-pinned and compatible active registrations compose without another plugin load. Co-authored-by: w33d <w33d@steadholme.local> * refactor(plugins): keep pinned registry lookup internal Reuse the existing runtime-state contract so hook and tool ownership does not expand the Plugin SDK surface or treat unpinned active registries as Gateway owners. * test(plugins): complete hook context fixture Supply the required tool name in the stateful hook ownership regression context. * chore: leave contributor release note in PR Normal contributor PRs do not modify the release-owned changelog; the PR body retains the release note and attribution. * fix(plugins): align partial registry owners * fix(plugins): preserve scoped tool diagnostics --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: w33d <w33d@steadholme.local>
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
PluginRegistry,
|
||||
PluginTrustedToolPolicyRegistryRegistration,
|
||||
} from "./registry-types.js";
|
||||
import { getPluginRegistryState } from "./runtime-state.js";
|
||||
import { collectLivePluginRegistries } from "./runtime.js";
|
||||
|
||||
type TrustedPolicyHookRunnerRegistry = GlobalHookRunnerRegistry & {
|
||||
@@ -47,11 +48,15 @@ function collectHookRegistrySources(
|
||||
seen.add(registry);
|
||||
ordered.push(registry);
|
||||
};
|
||||
// Precedence: the explicitly initialized registry wins so an SDK caller that
|
||||
// initializes an isolated registry stays authoritative; in the gateway it is
|
||||
// the same object as the active registry, so this just dedupes.
|
||||
add(lastInitialized);
|
||||
for (const registry of collectLivePluginRegistries()) {
|
||||
const liveRegistries = collectLivePluginRegistries();
|
||||
const initializedLiveRegistry = liveRegistries.some((registry) => registry === lastInitialized);
|
||||
// SDK callers can initialize an isolated registry and expect it to stay
|
||||
// authoritative. Runtime activations compose all live registries; owner
|
||||
// selection below aligns same-plugin hooks with their tool registry.
|
||||
if (!initializedLiveRegistry) {
|
||||
add(lastInitialized);
|
||||
}
|
||||
for (const registry of liveRegistries) {
|
||||
add(registry);
|
||||
}
|
||||
return ordered;
|
||||
@@ -84,6 +89,36 @@ function composeLiveHookRegistry(
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
const liveRegistries = collectLivePluginRegistries();
|
||||
if (lastInitialized && !liveRegistries.includes(lastInitialized as PluginRegistry)) {
|
||||
const isolatedSourceIndex = sources.indexOf(lastInitialized);
|
||||
if (isolatedSourceIndex >= 0) {
|
||||
for (const pluginId of expectDefined(
|
||||
hookPluginIdsBySource[isolatedSourceIndex],
|
||||
"isolated hook plugin ids",
|
||||
)) {
|
||||
claimOwner(pluginId, isolatedSourceIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
const claimToolOwners = (registry: PluginRegistry | null | undefined) => {
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
const sourceIndex = sources.indexOf(registry);
|
||||
if (sourceIndex < 0) {
|
||||
return;
|
||||
}
|
||||
for (const tool of registry.tools) {
|
||||
claimOwner(tool.pluginId, sourceIndex);
|
||||
}
|
||||
};
|
||||
const runtimeState = getPluginRegistryState();
|
||||
// Match tool resolution: an isolated initialized registry stays authoritative,
|
||||
// then the pinned Gateway owner wins only for tools it actually registered,
|
||||
// followed by the active registry for remaining tool owners.
|
||||
claimToolOwners(runtimeState?.channel.pinned ? runtimeState.channel.registry : null);
|
||||
claimToolOwners(runtimeState?.activeRegistry);
|
||||
// Prefer the highest-precedence source where the plugin loaded AND actually
|
||||
// contributes a hook, so a loaded-but-hookless record (failed/disabled scoped
|
||||
// reload, or a setup-runtime channel load) cannot shadow a lower-precedence
|
||||
|
||||
@@ -30,12 +30,105 @@ function runner() {
|
||||
return value;
|
||||
}
|
||||
|
||||
function addToolOwner(registry: PluginRegistry, pluginId: string, toolName: string) {
|
||||
registry.tools.push({
|
||||
pluginId,
|
||||
factory: () => [],
|
||||
names: [toolName],
|
||||
declaredNames: [toolName],
|
||||
optional: false,
|
||||
source: "test",
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
resetPluginRuntimeStateForTest();
|
||||
});
|
||||
|
||||
describe("global hook runner composition (#91918)", () => {
|
||||
describe("global hook runner composition (#91918, #107933)", () => {
|
||||
it("uses the pinned gateway owner when the active registry has the same plugin", async () => {
|
||||
const gatewayHook = vi.fn();
|
||||
const activeHook = vi.fn();
|
||||
const gateway = createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: gatewayHook, pluginId: "stateful" },
|
||||
]);
|
||||
const active = createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: activeHook, pluginId: "stateful" },
|
||||
]);
|
||||
addToolOwner(gateway, "stateful", "stateful_tool");
|
||||
addToolOwner(active, "stateful", "stateful_tool");
|
||||
|
||||
setActivePluginRegistry(gateway);
|
||||
pinActivePluginChannelRegistry(gateway);
|
||||
initializeGlobalHookRunner(gateway);
|
||||
setActivePluginRegistry(active);
|
||||
initializeGlobalHookRunner(active);
|
||||
|
||||
await runner().runBeforeToolCall(
|
||||
{ toolName: "stateful_tool", params: {} },
|
||||
{
|
||||
agentId: "test-agent",
|
||||
sessionKey: "test-session",
|
||||
toolCallId: "test-call",
|
||||
toolName: "stateful_tool",
|
||||
},
|
||||
);
|
||||
|
||||
// Plugin tools resolve from the pinned channel registry before the active
|
||||
// registry. Hooks for that plugin must use the same registration closure.
|
||||
expect(gatewayHook).toHaveBeenCalledOnce();
|
||||
expect(activeHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the active hook when the pinned registry has no matching tool owner", async () => {
|
||||
const gatewayHook = vi.fn();
|
||||
const activeHook = vi.fn();
|
||||
const gateway = createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: gatewayHook, pluginId: "conditional" },
|
||||
]);
|
||||
const active = createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: activeHook, pluginId: "conditional" },
|
||||
]);
|
||||
addToolOwner(active, "conditional", "conditional_tool");
|
||||
|
||||
setActivePluginRegistry(gateway);
|
||||
pinActivePluginChannelRegistry(gateway);
|
||||
initializeGlobalHookRunner(gateway);
|
||||
setActivePluginRegistry(active);
|
||||
initializeGlobalHookRunner(active);
|
||||
|
||||
await runner().runBeforeToolCall(
|
||||
{ toolName: "conditional_tool", params: {} },
|
||||
{
|
||||
agentId: "test-agent",
|
||||
sessionKey: "test-session",
|
||||
toolCallId: "test-call",
|
||||
toolName: "conditional_tool",
|
||||
},
|
||||
);
|
||||
|
||||
expect(activeHook).toHaveBeenCalledOnce();
|
||||
expect(gatewayHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not borrow a pinned hook when the active tool owner registered none", () => {
|
||||
const gateway = createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "conditional" },
|
||||
]);
|
||||
const active = createMockPluginRegistry([]);
|
||||
expectDefined(active.plugins[0], "active.plugins[0] test invariant").id = "conditional";
|
||||
addToolOwner(active, "conditional", "conditional_tool");
|
||||
|
||||
setActivePluginRegistry(gateway);
|
||||
pinActivePluginChannelRegistry(gateway);
|
||||
initializeGlobalHookRunner(gateway);
|
||||
setActivePluginRegistry(active);
|
||||
initializeGlobalHookRunner(active);
|
||||
|
||||
expect(runner().hasHooks("before_tool_call")).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers a loaded registration over a failed scoped reload of the same plugin", () => {
|
||||
const boot = createMockPluginRegistry([
|
||||
{ hookName: "before_tool_call", handler: vi.fn(), pluginId: "gate" },
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* and can be called from anywhere in the codebase.
|
||||
*
|
||||
* The runner is created once and resolves hooks live on every dispatch from a
|
||||
* composed view of the registries that are currently live: the most recently
|
||||
* initialized registry, the active registry, and the pinned channel/http-route
|
||||
* surfaces. Freezing one registry caused scoped mid-run activations (harness
|
||||
* composed view of the registries that are currently live: an explicitly
|
||||
* initialized SDK registry, the pinned channel registry, the active registry,
|
||||
* and other pinned surfaces. Freezing one registry caused scoped mid-run activations (harness
|
||||
* and memory ensures) to rebind the runner to a narrow registry and silently
|
||||
* drop other plugins' tool-call hooks (#91918). Composing live also preserves
|
||||
* the older contract that hooks pushed into a registry after initialization
|
||||
@@ -29,7 +29,8 @@ const getLog = () => createSubsystemLogger("plugins");
|
||||
* Initialize the global hook runner with a plugin registry.
|
||||
* Called on every plugin registry activation and by SDK consumers. The runner
|
||||
* instance stays stable so references captured mid-run keep seeing current
|
||||
* hooks; the passed registry becomes the highest-precedence composition source.
|
||||
* hooks. An isolated SDK registry stays authoritative; runtime registries use
|
||||
* the gateway surface precedence shared by plugin tool resolution.
|
||||
*/
|
||||
export function initializeGlobalHookRunner(registry: GlobalHookRunnerRegistry): void {
|
||||
const state = getHookRunnerGlobalState();
|
||||
|
||||
@@ -1025,7 +1025,7 @@ describe("resolvePluginTools optional tools", () => {
|
||||
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
|
||||
});
|
||||
|
||||
it("does not reuse a partial active registry for wildcard-selected plugin tools", () => {
|
||||
it("combines a partial active registry with missing wildcard-selected plugin tools", () => {
|
||||
const context = createContext();
|
||||
const config = context.config;
|
||||
const optionalEntry = createOptionalDemoEntry();
|
||||
@@ -1099,7 +1099,7 @@ describe("resolvePluginTools optional tools", () => {
|
||||
};
|
||||
expect(loaderParams.activate).toBe(false);
|
||||
expect(loaderParams.cache).toBe(false);
|
||||
expect(loaderParams.onlyPluginIds).toEqual(["multi", "optional-demo"]);
|
||||
expect(loaderParams.onlyPluginIds).toEqual(["optional-demo"]);
|
||||
expect(loaderParams.toolDiscovery).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1147,7 +1147,7 @@ describe("resolvePluginTools optional tools", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the fresh cold-loaded registry for diagnostics when partial active registries remain incomplete", () => {
|
||||
it("combines partial active and cold-loaded registries without false diagnostics", () => {
|
||||
const context = createContext();
|
||||
const config = context.config;
|
||||
const multiEntry: MockRegistryToolEntry = {
|
||||
@@ -1212,15 +1212,73 @@ describe("resolvePluginTools optional tools", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["optional_tool"]);
|
||||
expectResolvedToolNames(tools, ["other_tool", "optional_tool"]);
|
||||
expect(getActivePluginRegistry?.()).toBe(staleRegistry);
|
||||
expectSingleDiagnosticMessage(
|
||||
freshRegistry.diagnostics,
|
||||
"plugin tool registry did not include selected plugin tools after cold load (multi)",
|
||||
);
|
||||
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
|
||||
expect(freshRegistry.diagnostics).toStrictEqual([]);
|
||||
expect(staleRegistry.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("keeps missing-owner diagnostics on the request-local cold registry", () => {
|
||||
const context = createContext();
|
||||
const config = context.config;
|
||||
const multiEntry: MockRegistryToolEntry = {
|
||||
pluginId: "multi",
|
||||
optional: false,
|
||||
source: "/tmp/multi.js",
|
||||
names: ["other_tool"],
|
||||
declaredNames: ["other_tool"],
|
||||
factory: () => makeTool("other_tool"),
|
||||
};
|
||||
installToolManifestSnapshots({
|
||||
config,
|
||||
plugins: [
|
||||
{
|
||||
id: "multi",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["other_tool"] },
|
||||
},
|
||||
{
|
||||
id: "optional-demo",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["optional_tool"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const activeRegistry = createToolRegistry([multiEntry]);
|
||||
const coldRegistry = createToolRegistry([]);
|
||||
coldRegistry.plugins.push({ id: "optional-demo", origin: "bundled", status: "loaded" });
|
||||
setActivePluginRegistry?.(
|
||||
activeRegistry as never,
|
||||
"partial-test-tool-registry",
|
||||
"gateway-bindable",
|
||||
"/tmp",
|
||||
);
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
|
||||
loadOpenClawPluginsMock.mockReturnValue(coldRegistry);
|
||||
|
||||
const tools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context,
|
||||
toolAllowlist: ["*", "optional-demo"],
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["other_tool"]);
|
||||
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
|
||||
expectSingleDiagnosticMessage(
|
||||
coldRegistry.diagnostics,
|
||||
"plugin tool registry did not include selected plugin tools after cold load (optional-demo)",
|
||||
);
|
||||
expect(activeRegistry.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not reuse a pinned gateway registry for manifest-unavailable tools", () => {
|
||||
const config = createContext().config;
|
||||
installToolManifestSnapshot({
|
||||
@@ -3263,21 +3321,16 @@ describe("resolvePluginTools optional tools", () => {
|
||||
toolAllowlist: ["*", "tavily"],
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
const runtimeRegistryParams = mockCallParams(resolveRuntimePluginRegistryMock) as {
|
||||
onlyPluginIds?: string[];
|
||||
toolDiscovery?: unknown;
|
||||
};
|
||||
expect(runtimeRegistryParams.onlyPluginIds).toContain("tavily");
|
||||
expect(runtimeRegistryParams.toolDiscovery).toBe(true);
|
||||
expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled();
|
||||
const loaderParams = mockCallParams(loadOpenClawPluginsMock) as {
|
||||
onlyPluginIds?: string[];
|
||||
toolDiscovery?: unknown;
|
||||
};
|
||||
expect(loaderParams.onlyPluginIds).toContain("tavily");
|
||||
expect(loaderParams.onlyPluginIds).toEqual(["tavily"]);
|
||||
expect(loaderParams.toolDiscovery).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses the pinned gateway channel registry after provider runtime loads replace active registry", () => {
|
||||
it("reuses the pinned gateway registry across workspace-scoped provider runtime loads", () => {
|
||||
const gatewayRegistry = createOptionalDemoActiveRegistry();
|
||||
setActivePluginRegistry(
|
||||
gatewayRegistry as never,
|
||||
@@ -3298,7 +3351,7 @@ describe("resolvePluginTools optional tools", () => {
|
||||
} as never,
|
||||
"provider-runtime",
|
||||
"default",
|
||||
"/tmp",
|
||||
"/tmp/provider-workspace",
|
||||
);
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
|
||||
|
||||
@@ -3314,6 +3367,233 @@ describe("resolvePluginTools optional tools", () => {
|
||||
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads only tool plugins missing from the pinned gateway registry", () => {
|
||||
const gatewayFactory = vi.fn(() => makeTool("optional_tool"));
|
||||
const standaloneFactory = vi.fn(() => makeTool("other_tool"));
|
||||
const gatewayRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: ["optional_tool"],
|
||||
factory: gatewayFactory,
|
||||
},
|
||||
]);
|
||||
const standaloneRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "multi",
|
||||
optional: false,
|
||||
source: "/tmp/multi.js",
|
||||
names: ["other_tool"],
|
||||
factory: standaloneFactory,
|
||||
},
|
||||
]);
|
||||
const config = createContext().config;
|
||||
installToolManifestSnapshots({
|
||||
config,
|
||||
plugins: [
|
||||
{
|
||||
id: "optional-demo",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["optional_tool"] },
|
||||
},
|
||||
{
|
||||
id: "multi",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["other_tool"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
gatewayRegistry as never,
|
||||
"gateway-startup",
|
||||
"gateway-bindable",
|
||||
"/tmp",
|
||||
);
|
||||
pinActivePluginChannelRegistry(gatewayRegistry as never);
|
||||
setActivePluginRegistry(
|
||||
createEmptyPluginRegistry(),
|
||||
"provider-runtime",
|
||||
"default",
|
||||
"/tmp/provider-workspace",
|
||||
);
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
|
||||
loadOpenClawPluginsMock.mockReturnValue(standaloneRegistry);
|
||||
|
||||
const tools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), config },
|
||||
toolAllowlist: ["optional_tool", "other_tool"],
|
||||
allowGatewaySubagentBinding: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["optional_tool", "other_tool"]);
|
||||
expect(gatewayFactory).toHaveBeenCalledOnce();
|
||||
expect(standaloneFactory).toHaveBeenCalledOnce();
|
||||
expectLoaderSelectedOnlyPluginIds(["multi"]);
|
||||
});
|
||||
|
||||
it("combines pinned gateway and compatible active tool owners", () => {
|
||||
const gatewayFactory = vi.fn(() => makeTool("optional_tool"));
|
||||
const activeFactory = vi.fn(() => makeTool("other_tool"));
|
||||
const gatewayRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: ["optional_tool"],
|
||||
factory: gatewayFactory,
|
||||
},
|
||||
]);
|
||||
const activeRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "multi",
|
||||
optional: false,
|
||||
source: "/tmp/multi.js",
|
||||
names: ["other_tool"],
|
||||
factory: activeFactory,
|
||||
},
|
||||
]);
|
||||
const config = createContext().config;
|
||||
installToolManifestSnapshots({
|
||||
config,
|
||||
plugins: [
|
||||
{
|
||||
id: "optional-demo",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["optional_tool"] },
|
||||
},
|
||||
{
|
||||
id: "multi",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["other_tool"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
gatewayRegistry as never,
|
||||
"gateway-startup",
|
||||
"gateway-bindable",
|
||||
"/tmp",
|
||||
);
|
||||
pinActivePluginChannelRegistry(gatewayRegistry as never);
|
||||
setActivePluginRegistry(activeRegistry as never, "provider-runtime", "default", "/tmp");
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
|
||||
|
||||
const tools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), config },
|
||||
toolAllowlist: ["optional_tool", "other_tool"],
|
||||
allowGatewaySubagentBinding: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["optional_tool", "other_tool"]);
|
||||
expect(gatewayFactory).toHaveBeenCalledOnce();
|
||||
expect(activeFactory).toHaveBeenCalledOnce();
|
||||
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("combines partial active owners before loading only the still-missing plugin", () => {
|
||||
const gatewayFactory = vi.fn(() => makeTool("optional_tool"));
|
||||
const activeFactory = vi.fn(() => makeTool("other_tool"));
|
||||
const standaloneFactory = vi.fn(() => makeTool("third_tool"));
|
||||
const gatewayRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "optional-demo",
|
||||
optional: true,
|
||||
source: "/tmp/optional-demo.js",
|
||||
names: ["optional_tool"],
|
||||
factory: gatewayFactory,
|
||||
},
|
||||
]);
|
||||
const activeRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "multi",
|
||||
optional: false,
|
||||
source: "/tmp/multi.js",
|
||||
names: ["other_tool"],
|
||||
factory: activeFactory,
|
||||
},
|
||||
]);
|
||||
const standaloneRegistry = createToolRegistry([
|
||||
{
|
||||
pluginId: "third",
|
||||
optional: false,
|
||||
source: "/tmp/third.js",
|
||||
names: ["third_tool"],
|
||||
factory: standaloneFactory,
|
||||
},
|
||||
]);
|
||||
const config = createContext().config;
|
||||
installToolManifestSnapshots({
|
||||
config,
|
||||
plugins: [
|
||||
{
|
||||
id: "optional-demo",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["optional_tool"] },
|
||||
},
|
||||
{
|
||||
id: "multi",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["other_tool"] },
|
||||
},
|
||||
{
|
||||
id: "third",
|
||||
origin: "bundled",
|
||||
enabledByDefault: true,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: { tools: ["third_tool"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
gatewayRegistry as never,
|
||||
"gateway-startup",
|
||||
"gateway-bindable",
|
||||
"/tmp",
|
||||
);
|
||||
pinActivePluginChannelRegistry(gatewayRegistry as never);
|
||||
setActivePluginRegistry(activeRegistry as never, "provider-runtime", "default", "/tmp");
|
||||
resolveRuntimePluginRegistryMock.mockReturnValue(activeRegistry);
|
||||
loadOpenClawPluginsMock.mockReturnValue(standaloneRegistry);
|
||||
|
||||
const tools = resolvePluginTools(
|
||||
createResolveToolsParams({
|
||||
context: { ...createContext(), config },
|
||||
toolAllowlist: ["optional_tool", "other_tool", "third_tool"],
|
||||
allowGatewaySubagentBinding: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expectResolvedToolNames(tools, ["optional_tool", "other_tool", "third_tool"]);
|
||||
expect(gatewayFactory).toHaveBeenCalledOnce();
|
||||
expect(activeFactory).toHaveBeenCalledOnce();
|
||||
expect(standaloneFactory).toHaveBeenCalledOnce();
|
||||
expectLoaderSelectedOnlyPluginIds(["third"]);
|
||||
});
|
||||
|
||||
it("reuses the pinned gateway channel registry even when the caller omits gateway binding", () => {
|
||||
const gatewayRegistry = createOptionalDemoActiveRegistry();
|
||||
setActivePluginRegistry(
|
||||
|
||||
+140
-30
@@ -25,6 +25,7 @@ import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { hasManifestToolAvailability } from "./manifest-tool-availability.js";
|
||||
import type { PluginMetadataManifestView } from "./plugin-metadata-snapshot.types.js";
|
||||
import type { PluginRegistry, PluginToolRegistration } from "./registry-types.js";
|
||||
import { getPluginRegistryState } from "./runtime-state.js";
|
||||
import { withPluginRuntimePluginScope } from "./runtime/gateway-request-scope.js";
|
||||
import {
|
||||
buildPluginRuntimeLoadOptions,
|
||||
@@ -999,35 +1000,105 @@ function resolvePluginToolRegistry(params: {
|
||||
retainedRegistry?: PluginRegistry;
|
||||
onRetainRegistry?: (registry: PluginRegistry) => void;
|
||||
}) {
|
||||
const lookup = {
|
||||
env: params.loadOptions.env,
|
||||
loadOptions: params.loadOptions,
|
||||
workspaceDir: params.loadOptions.workspaceDir,
|
||||
requiredPluginIds: params.onlyPluginIds,
|
||||
const requestedPluginIds = params.onlyPluginIds;
|
||||
// Retained registries belong to one cached descriptor execution. Reusing one
|
||||
// across a multi-owner request would keep its tools but lose its hook state
|
||||
// when another owner triggers a fresh scoped load.
|
||||
const retainedRegistry =
|
||||
requestedPluginIds === undefined || requestedPluginIds.length === 1
|
||||
? params.retainedRegistry
|
||||
: undefined;
|
||||
const registries: PluginRegistry[] = [];
|
||||
const seenRegistries = new Set<PluginRegistry>();
|
||||
const ownerRegistryByPluginId = new Map<string, PluginRegistry>();
|
||||
const addRegistry = (registry: PluginRegistry | undefined) => {
|
||||
if (!registry || seenRegistries.has(registry)) {
|
||||
return;
|
||||
}
|
||||
seenRegistries.add(registry);
|
||||
registries.push(registry);
|
||||
if (requestedPluginIds === undefined) {
|
||||
return;
|
||||
}
|
||||
const toolPluginIds = new Set(registry.tools.map((entry) => entry.pluginId));
|
||||
for (const pluginId of requestedPluginIds) {
|
||||
if (toolPluginIds.has(pluginId) && !ownerRegistryByPluginId.has(pluginId)) {
|
||||
ownerRegistryByPluginId.set(pluginId, registry);
|
||||
}
|
||||
}
|
||||
};
|
||||
const channelRegistry = getLoadedRuntimePluginRegistry({
|
||||
...lookup,
|
||||
surface: "channel",
|
||||
});
|
||||
if (registryHasScopedPluginTools(channelRegistry, params.onlyPluginIds)) {
|
||||
return channelRegistry;
|
||||
const missingPluginIds = () =>
|
||||
requestedPluginIds?.filter((pluginId) => !ownerRegistryByPluginId.has(pluginId));
|
||||
const composeSelectedRegistries = () =>
|
||||
composePluginToolRegistries({
|
||||
registries,
|
||||
ownerRegistryByPluginId,
|
||||
requestedPluginIds: requestedPluginIds ?? [],
|
||||
});
|
||||
|
||||
// Use the established pinned-Gateway owner; its factories receive request
|
||||
// context directly. Reapplying active-scope metadata would duplicate the
|
||||
// registration and split hook/tool closure state.
|
||||
const runtimeState = getPluginRegistryState();
|
||||
const gatewayRegistry = runtimeState?.channel.pinned
|
||||
? (runtimeState.channel.registry ?? undefined)
|
||||
: undefined;
|
||||
if (
|
||||
requestedPluginIds === undefined &&
|
||||
registryHasScopedPluginTools(gatewayRegistry, undefined)
|
||||
) {
|
||||
return gatewayRegistry;
|
||||
}
|
||||
addRegistry(gatewayRegistry);
|
||||
let requiredPluginIds = missingPluginIds();
|
||||
if (requiredPluginIds?.length === 0) {
|
||||
return composeSelectedRegistries();
|
||||
}
|
||||
|
||||
const activeRegistry = getLoadedRuntimePluginRegistry({
|
||||
env: lookup.env,
|
||||
workspaceDir: lookup.workspaceDir,
|
||||
requiredPluginIds: lookup.requiredPluginIds,
|
||||
surface: "active",
|
||||
});
|
||||
if (registryHasScopedPluginTools(activeRegistry, params.onlyPluginIds)) {
|
||||
let activeRegistry: PluginRegistry | undefined;
|
||||
if (requiredPluginIds === undefined) {
|
||||
activeRegistry = getLoadedRuntimePluginRegistry({
|
||||
env: params.loadOptions.env,
|
||||
workspaceDir: params.loadOptions.workspaceDir,
|
||||
surface: "active",
|
||||
});
|
||||
} else {
|
||||
for (const pluginId of requiredPluginIds) {
|
||||
activeRegistry = getLoadedRuntimePluginRegistry({
|
||||
env: params.loadOptions.env,
|
||||
workspaceDir: params.loadOptions.workspaceDir,
|
||||
requiredPluginIds: [pluginId],
|
||||
surface: "active",
|
||||
});
|
||||
if (activeRegistry) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requestedPluginIds === undefined && registryHasScopedPluginTools(activeRegistry, undefined)) {
|
||||
return activeRegistry;
|
||||
}
|
||||
|
||||
if (registryHasScopedPluginTools(params.retainedRegistry, params.onlyPluginIds)) {
|
||||
return params.retainedRegistry;
|
||||
addRegistry(activeRegistry);
|
||||
requiredPluginIds = missingPluginIds();
|
||||
if (requiredPluginIds?.length === 0) {
|
||||
return composeSelectedRegistries();
|
||||
}
|
||||
|
||||
const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry);
|
||||
if (
|
||||
requestedPluginIds === undefined &&
|
||||
registryHasScopedPluginTools(retainedRegistry, undefined)
|
||||
) {
|
||||
return retainedRegistry;
|
||||
}
|
||||
addRegistry(retainedRegistry);
|
||||
requiredPluginIds = missingPluginIds();
|
||||
if (requiredPluginIds?.length === 0) {
|
||||
return composeSelectedRegistries();
|
||||
}
|
||||
// Partial active/retained registries contribute their matching owners, but
|
||||
// missing requested owners still force a fresh load. Plugin records alone
|
||||
// do not prove that the executable tool registrations are available.
|
||||
const forceStandaloneLoad = Boolean(gatewayRegistry || activeRegistry || retainedRegistry);
|
||||
const shouldRetainColdLoadedToolRegistry =
|
||||
forceStandaloneLoad &&
|
||||
params.loadOptions.activate === false &&
|
||||
@@ -1037,16 +1108,55 @@ function resolvePluginToolRegistry(params: {
|
||||
surface: "active",
|
||||
forceLoad: forceStandaloneLoad,
|
||||
installRegistry: !forceStandaloneLoad,
|
||||
requiredPluginIds: params.onlyPluginIds,
|
||||
loadOptions: params.loadOptions,
|
||||
requiredPluginIds,
|
||||
loadOptions:
|
||||
requestedPluginIds === undefined
|
||||
? params.loadOptions
|
||||
: { ...params.loadOptions, onlyPluginIds: requiredPluginIds },
|
||||
});
|
||||
if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) {
|
||||
if (shouldRetainColdLoadedToolRegistry) {
|
||||
params.onRetainRegistry?.(standaloneRegistry);
|
||||
}
|
||||
return standaloneRegistry;
|
||||
if (standaloneRegistry && shouldRetainColdLoadedToolRegistry) {
|
||||
params.onRetainRegistry?.(standaloneRegistry);
|
||||
}
|
||||
return standaloneRegistry ?? channelRegistry ?? activeRegistry;
|
||||
addRegistry(standaloneRegistry);
|
||||
if (requestedPluginIds === undefined) {
|
||||
return standaloneRegistry ?? gatewayRegistry ?? activeRegistry;
|
||||
}
|
||||
return composeSelectedRegistries();
|
||||
}
|
||||
|
||||
function composePluginToolRegistries(params: {
|
||||
registries: PluginRegistry[];
|
||||
ownerRegistryByPluginId: ReadonlyMap<string, PluginRegistry>;
|
||||
requestedPluginIds: readonly string[];
|
||||
}): PluginRegistry | undefined {
|
||||
if (params.registries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const contributingRegistries = params.registries.filter((registry) =>
|
||||
params.requestedPluginIds.some(
|
||||
(pluginId) => params.ownerRegistryByPluginId.get(pluginId) === registry,
|
||||
),
|
||||
);
|
||||
const baseRegistry = params.registries.at(-1)!;
|
||||
if (contributingRegistries.length === 1 && contributingRegistries[0] === baseRegistry) {
|
||||
return baseRegistry;
|
||||
}
|
||||
const selectedPluginIds = new Set(params.requestedPluginIds);
|
||||
return {
|
||||
...baseRegistry,
|
||||
plugins: contributingRegistries.flatMap((registry) =>
|
||||
registry.plugins.filter(
|
||||
(plugin) =>
|
||||
selectedPluginIds.has(plugin.id) &&
|
||||
params.ownerRegistryByPluginId.get(plugin.id) === registry,
|
||||
),
|
||||
),
|
||||
tools: contributingRegistries.flatMap((registry) =>
|
||||
registry.tools.filter(
|
||||
(entry) => params.ownerRegistryByPluginId.get(entry.pluginId) === registry,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function registryHasScopedPluginTools(
|
||||
|
||||
Reference in New Issue
Block a user