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

@@ -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 },