fix(plugins): late-binding subagent runtime for non-gateway load paths (#46648)

Merged via squash.

Prepared head SHA: 44742652c9
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman
2026-03-16 14:27:54 -07:00
committed by GitHub
parent abce640772
commit eeb140b4f0
42 changed files with 555 additions and 28 deletions

View File

@@ -926,6 +926,44 @@ module.exports = { id: "skipped", register() { throw new Error("skipped plugin s
expect(third).toBe(second);
});
it("does not reuse cached registries across gateway subagent binding modes", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "cache-gateway-bindable",
filename: "cache-gateway-bindable.cjs",
body: `module.exports = { id: "cache-gateway-bindable", register() {} };`,
});
const options = {
workspaceDir: plugin.dir,
config: {
plugins: {
allow: ["cache-gateway-bindable"],
load: {
paths: [plugin.file],
},
},
},
};
const defaultRegistry = loadOpenClawPlugins(options);
const gatewayBindableRegistry = loadOpenClawPlugins({
...options,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
const gatewayBindableAgain = loadOpenClawPlugins({
...options,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
expect(gatewayBindableRegistry).not.toBe(defaultRegistry);
expect(gatewayBindableAgain).toBe(gatewayBindableRegistry);
});
it("evicts least recently used registries when the loader cache exceeds its cap", () => {
useNoBundledPlugins();
const plugin = writePlugin({

View File

@@ -314,6 +314,7 @@ function buildCacheKey(params: {
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
@@ -344,7 +345,7 @@ function buildCacheKey(params: {
...params.plugins,
installs,
loadPaths,
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}`;
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}`;
}
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
@@ -802,6 +803,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
onlyPluginIds,
includeSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
runtimeSubagentMode:
options.runtimeOptions?.allowGatewaySubagentBinding === true
? "gateway-bindable"
: options.runtimeOptions?.subagent
? "explicit"
: "default",
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {

View File

@@ -10,11 +10,16 @@ vi.mock("../../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
import { createPluginRuntime } from "./index.js";
import {
clearGatewaySubagentRuntime,
createPluginRuntime,
setGatewaySubagentRuntime,
} from "./index.js";
describe("plugin runtime command execution", () => {
beforeEach(() => {
runCommandWithTimeoutMock.mockClear();
clearGatewaySubagentRuntime();
});
it("exposes runtime.system.runCommandWithTimeout by default", async () => {
@@ -82,4 +87,37 @@ describe("plugin runtime command execution", () => {
// Wrappers should NOT be the same reference as the raw functions
expect(runtime.modelAuth.getApiKeyForModel).not.toBe(rawGetApiKey);
});
it("keeps subagent unavailable by default even after gateway initialization", async () => {
const runtime = createPluginRuntime();
setGatewaySubagentRuntime({
run: vi.fn(),
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),
deleteSession: vi.fn(),
});
expect(() => runtime.subagent.run({ sessionKey: "s-1", message: "hello" })).toThrow(
"Plugin runtime subagent methods are only available during a gateway request.",
);
});
it("late-binds to the gateway subagent when explicitly enabled", async () => {
const run = vi.fn().mockResolvedValue({ runId: "run-1" });
const runtime = createPluginRuntime({ allowGatewaySubagentBinding: true });
setGatewaySubagentRuntime({
run,
waitForRun: vi.fn(),
getSessionMessages: vi.fn(),
getSession: vi.fn(),
deleteSession: vi.fn(),
});
await expect(runtime.subagent.run({ sessionKey: "s-2", message: "hello" })).resolves.toEqual({
runId: "run-1",
});
expect(run).toHaveBeenCalledWith({ sessionKey: "s-2", message: "hello" });
});
});

View File

@@ -46,8 +46,82 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] {
};
}
// ── Process-global gateway subagent runtime ─────────────────────────
// The gateway creates a real subagent runtime during startup, but gateway-owned
// plugin registries may be loaded (and cached) before the gateway path runs.
// A process-global holder lets explicitly gateway-bindable runtimes resolve the
// active gateway subagent dynamically without changing the default behavior for
// ordinary plugin runtimes.
const GATEWAY_SUBAGENT_SYMBOL: unique symbol = Symbol.for(
"openclaw.plugin.gatewaySubagentRuntime",
) as unknown as typeof GATEWAY_SUBAGENT_SYMBOL;
type GatewaySubagentState = {
subagent: PluginRuntime["subagent"] | undefined;
};
const gatewaySubagentState: GatewaySubagentState = (() => {
const g = globalThis as typeof globalThis & {
[GATEWAY_SUBAGENT_SYMBOL]?: GatewaySubagentState;
};
const existing = g[GATEWAY_SUBAGENT_SYMBOL];
if (existing) {
return existing;
}
const created: GatewaySubagentState = { subagent: undefined };
g[GATEWAY_SUBAGENT_SYMBOL] = created;
return created;
})();
/**
* Set the process-global gateway subagent runtime.
* Called during gateway startup so that gateway-bindable plugin runtimes can
* resolve subagent methods dynamically even when their registry was cached
* before the gateway finished loading plugins.
*/
export function setGatewaySubagentRuntime(subagent: PluginRuntime["subagent"]): void {
gatewaySubagentState.subagent = subagent;
}
/**
* Reset the process-global gateway subagent runtime.
* Used by tests to avoid leaking gateway state across module reloads.
*/
export function clearGatewaySubagentRuntime(): void {
gatewaySubagentState.subagent = undefined;
}
/**
* Create a late-binding subagent that resolves to:
* 1. An explicitly provided subagent (from runtimeOptions), OR
* 2. The process-global gateway subagent when the caller explicitly opts in, OR
* 3. The unavailable fallback (throws with a clear error message).
*/
function createLateBindingSubagent(
explicit?: PluginRuntime["subagent"],
allowGatewaySubagentBinding = false,
): PluginRuntime["subagent"] {
if (explicit) {
return explicit;
}
const unavailable = createUnavailableSubagentRuntime();
if (!allowGatewaySubagentBinding) {
return unavailable;
}
return new Proxy(unavailable, {
get(_target, prop, _receiver) {
const resolved = gatewaySubagentState.subagent ?? unavailable;
return Reflect.get(resolved, prop, resolved);
},
});
}
export type CreatePluginRuntimeOptions = {
subagent?: PluginRuntime["subagent"];
allowGatewaySubagentBinding?: boolean;
};
export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): PluginRuntime {
@@ -55,7 +129,10 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}):
version: resolveVersion(),
config: createRuntimeConfig(),
agent: createRuntimeAgent(),
subagent: _options.subagent ?? createUnavailableSubagentRuntime(),
subagent: createLateBindingSubagent(
_options.subagent,
_options.allowGatewaySubagentBinding === true,
),
system: createRuntimeSystem(),
media: createRuntimeMedia(),
tts: { textToSpeechTelephony },

View File

@@ -170,4 +170,22 @@ describe("resolvePluginTools optional tools", () => {
}),
);
});
it("forwards gateway subagent binding to plugin runtime options", () => {
setOptionalDemoRegistry();
resolvePluginTools({
context: createContext() as never,
allowGatewaySubagentBinding: true,
toolAllowlist: ["optional_tool"],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
expect.objectContaining({
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
}),
);
});
});

View File

@@ -47,6 +47,7 @@ export function resolvePluginTools(params: {
existingToolNames?: Set<string>;
toolAllowlist?: string[];
suppressNameConflicts?: boolean;
allowGatewaySubagentBinding?: boolean;
env?: NodeJS.ProcessEnv;
}): AnyAgentTool[] {
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
@@ -61,6 +62,11 @@ export function resolvePluginTools(params: {
const registry = loadOpenClawPlugins({
config: effectiveConfig,
workspaceDir: params.context.workspaceDir,
runtimeOptions: params.allowGatewaySubagentBinding
? {
allowGatewaySubagentBinding: true,
}
: undefined,
env,
logger: createPluginLoaderLogger(log),
});