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

@@ -103,6 +103,7 @@ Docs: https://docs.openclaw.ai
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk.
- Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman.
- Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj.
## 2026.3.13

View File

@@ -5,9 +5,10 @@ import type {
SetSessionConfigOptionRequest,
SetSessionModeRequest,
} from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import type { EventFrame } from "../gateway/protocol/index.js";
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
@@ -119,6 +120,10 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text:
sessionStore.clearAllSessionsForTest();
}
beforeEach(() => {
resetProviderRuntimeHookCacheForTest();
});
describe("acp session creation rate limit", () => {
it("rate limits excessive newSession bursts", async () => {
const sessionStore = createInMemorySessionStore();
@@ -297,7 +302,14 @@ describe("acp session UX bridge behavior", () => {
const result = await agent.loadSession(createLoadSessionRequest("agent:main:work"));
expect(result.modes?.currentModeId).toBe("high");
expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh");
expect(result.modes?.availableModes.map((mode) => mode.id)).toEqual([
"off",
"minimal",
"low",
"medium",
"high",
"adaptive",
]);
expect(result.configOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { resolvePluginToolsMock } = vi.hoisted(() => ({
resolvePluginToolsMock: vi.fn((params?: unknown) => {
@@ -9,11 +9,17 @@ const { resolvePluginToolsMock } = vi.hoisted(() => ({
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: resolvePluginToolsMock,
getPluginToolMeta: vi.fn(() => undefined),
}));
import { createOpenClawTools } from "./openclaw-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
describe("createOpenClawTools plugin context", () => {
beforeEach(() => {
resolvePluginToolsMock.mockClear();
});
it("forwards trusted requester sender identity to plugin tool context", () => {
createOpenClawTools({
config: {} as never,
@@ -47,4 +53,30 @@ describe("createOpenClawTools plugin context", () => {
}),
);
});
it("forwards gateway subagent binding for plugin tools", () => {
createOpenClawTools({
config: {} as never,
allowGatewaySubagentBinding: true,
});
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
}),
);
});
it("forwards gateway subagent binding through coding tools", () => {
createOpenClawCodingTools({
config: {} as never,
allowGatewaySubagentBinding: true,
});
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
}),
);
});
});

View File

@@ -80,6 +80,8 @@ export function createOpenClawTools(
spawnWorkspaceDir?: string;
/** Callback invoked when sessions_yield tool is called. */
onYield?: (message: string) => Promise<void> | void;
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
allowGatewaySubagentBinding?: boolean;
} & SpawnedToolContext,
): AnyAgentTool[] {
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
@@ -235,6 +237,7 @@ export function createOpenClawTools(
},
existingToolNames: new Set(tools.map((tool) => tool.name)),
toolAllowlist: options?.pluginToolAllowlist,
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
});
return [...tools, ...pluginTools];

View File

@@ -15,6 +15,7 @@ const {
resolveSessionAgentIdMock,
estimateTokensMock,
sessionAbortCompactionMock,
createOpenClawCodingToolsMock,
} = vi.hoisted(() => {
const contextEngineCompactMock = vi.fn(async () => ({
ok: true as boolean,
@@ -36,12 +37,14 @@ const {
info: { ownsCompaction: true },
compact: contextEngineCompactMock,
})),
resolveModelMock: vi.fn(() => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
})),
resolveModelMock: vi.fn(
(_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
}),
),
sessionCompactImpl: vi.fn(async () => ({
summary: "summary",
firstKeptEntryId: "entry-1",
@@ -67,6 +70,7 @@ const {
resolveSessionAgentIdMock: vi.fn(() => "main"),
estimateTokensMock: vi.fn((_message?: unknown) => 10),
sessionAbortCompactionMock: vi.fn(),
createOpenClawCodingToolsMock: vi.fn(() => []),
};
});
@@ -205,7 +209,7 @@ vi.mock("../channel-tools.js", () => ({
}));
vi.mock("../pi-tools.js", () => ({
createOpenClawCodingTools: vi.fn(() => []),
createOpenClawCodingTools: createOpenClawCodingToolsMock,
}));
vi.mock("./google.js", () => ({
@@ -307,6 +311,10 @@ vi.mock("./sandbox-info.js", () => ({
vi.mock("./model.js", () => ({
buildModelAliasLines: vi.fn(() => []),
resolveModel: resolveModelMock,
resolveModelAsync: vi.fn(
async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) =>
resolveModelMock(provider, modelId, agentDir, cfg),
),
}));
vi.mock("./session-manager-cache.js", () => ({
@@ -449,6 +457,26 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
});
});
it("forwards gateway subagent binding opt-in during compaction bootstrap", async () => {
await compactEmbeddedPiSessionDirect({
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
});
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: undefined,
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
});
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
}),
);
});
it("emits internal + plugin compaction hooks with counts", async () => {
hookRunner.hasHooks.mockReturnValue(true);
let sanitizedCount = 0;

View File

@@ -147,6 +147,8 @@ export type CompactEmbeddedPiSessionParams = {
extraSystemPrompt?: string;
ownerNumbers?: string[];
abortSignal?: AbortSignal;
/** Allow runtime plugins for this compaction to late-bind the gateway subagent. */
allowGatewaySubagentBinding?: boolean;
};
type CompactionMessageMetrics = {
@@ -384,6 +386,7 @@ export async function compactEmbeddedPiSessionDirect(
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: resolvedWorkspace,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
});
const prevCwd = process.cwd();
@@ -570,6 +573,7 @@ export async function compactEmbeddedPiSessionDirect(
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
@@ -1086,6 +1090,7 @@ export async function compactEmbeddedPiSession(
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: params.workspaceDir,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
});
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);

View File

@@ -156,6 +156,19 @@ vi.mock("./model.js", () => ({
},
modelRegistry: {},
})),
resolveModelAsync: vi.fn(async () => ({
model: {
id: "test-model",
provider: "anthropic",
contextWindow: 200000,
api: "messages",
},
error: null,
authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
})),
}));
vi.mock("../model-auth.js", () => ({

View File

@@ -302,6 +302,7 @@ export async function runEmbeddedPiAgent(
ensureRuntimePluginsLoaded({
config: params.config,
workspaceDir: resolvedWorkspace,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
});
const prevCwd = process.cwd();
@@ -952,6 +953,7 @@ export async function runEmbeddedPiAgent(
workspaceDir: resolvedWorkspace,
agentDir,
config: params.config,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
contextEngine,
contextTokenBudget: ctxInfo.tokens,
skillsSnapshot: params.skillsSnapshot,

View File

@@ -1508,6 +1508,7 @@ export async function runEmbeddedAttempt(
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,

View File

@@ -63,6 +63,8 @@ export type RunEmbeddedPiAgentParams = {
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
/** Allow runtime plugins for this run to late-bind the gateway subagent. */
allowGatewaySubagentBinding?: boolean;
sessionFile: string;
workspaceDir: string;
agentDir?: string;

View File

@@ -45,6 +45,39 @@ describe("runEmbeddedPiAgent usage reporting", () => {
});
});
it("forwards gateway subagent binding opt-in to runtime plugin bootstrap", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,
promptError: null,
timedOut: false,
sessionIdUsed: "test-session",
assistantTexts: ["Response 1"],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
await runEmbeddedPiAgent({
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "hello",
timeoutMs: 30000,
runId: "run-gateway-bind",
allowGatewaySubagentBinding: true,
});
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: undefined,
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
}),
);
});
it("forwards sender identity fields into embedded attempts", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
aborted: false,

View File

@@ -259,6 +259,8 @@ export function createOpenClawCodingTools(options?: {
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
/** Allow plugin tools for this run to late-bind the gateway subagent. */
allowGatewaySubagentBinding?: boolean;
/** If true, the model has native vision capability */
modelHasVision?: boolean;
/** Require explicit message targets (no implicit last-route sends). */
@@ -535,6 +537,7 @@ export function createOpenClawCodingTools(options?: {
senderIsOwner: options?.senderIsOwner,
sessionId: options?.sessionId,
onYield: options?.onYield,
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
}),
];
const toolsForMemoryFlush =

View File

@@ -5,6 +5,7 @@ import { resolveUserPath } from "../utils.js";
export function ensureRuntimePluginsLoaded(params: {
config?: OpenClawConfig;
workspaceDir?: string | null;
allowGatewaySubagentBinding?: boolean;
}): void {
const workspaceDir =
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
@@ -14,5 +15,10 @@ export function ensureRuntimePluginsLoaded(params: {
loadOpenClawPlugins({
config: params.config,
workspaceDir,
runtimeOptions: params.allowGatewaySubagentBinding
? {
allowGatewaySubagentBinding: true,
}
: undefined,
});
}

View File

@@ -79,6 +79,7 @@ describe("subagent-registry context-engine bootstrap", () => {
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
config: {},
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
});
});
expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1);

View File

@@ -322,6 +322,7 @@ async function notifyContextEngineSubagentEnded(params: {
ensureRuntimePluginsLoaded({
config: cfg,
workspaceDir: params.workspaceDir,
allowGatewaySubagentBinding: true,
});
ensureContextEnginesInitialized();
const engine = await resolveContextEngine(cfg);

View File

@@ -323,6 +323,7 @@ export async function runAgentTurnWithFallback(params: {
try {
const result = await runEmbeddedPiAgent({
...embeddedContext,
allowGatewaySubagentBinding: true,
trigger: params.isHeartbeat ? "heartbeat" : "user",
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:

View File

@@ -494,6 +494,7 @@ export async function runMemoryFlushIfNeeded(params: {
...embeddedContext,
...senderContext,
...runBaseParams,
allowGatewaySubagentBinding: true,
trigger: "memory",
memoryFlushWritePath,
prompt: resolveMemoryFlushPromptForRun({

View File

@@ -78,6 +78,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
const result = await compactEmbeddedPiSession({
sessionId,
sessionKey: params.sessionKey,
allowGatewaySubagentBinding: true,
messageChannel: params.command.channel,
groupId: params.sessionEntry.groupId,
groupChannel: params.sessionEntry.groupChannel,

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HandleCommandsParams } from "./commands-types.js";
const { createOpenClawCodingToolsMock } = vi.hoisted(() => ({
createOpenClawCodingToolsMock: vi.fn(() => []),
}));
vi.mock("../../agents/bootstrap-files.js", () => ({
resolveBootstrapContextForRun: vi.fn(async () => ({
bootstrapFiles: [],
contextFiles: [],
})),
}));
vi.mock("../../agents/pi-tools.js", () => ({
createOpenClawCodingTools: createOpenClawCodingToolsMock,
}));
vi.mock("../../agents/sandbox.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false, mode: "off" })),
}));
vi.mock("../../agents/skills.js", () => ({
buildWorkspaceSkillSnapshot: vi.fn(() => ({ prompt: "", skills: [], resolvedSkills: [] })),
}));
vi.mock("../../agents/skills/refresh.js", () => ({
getSkillsSnapshotVersion: vi.fn(() => "test-snapshot"),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveSessionAgentIds: vi.fn(() => ({ sessionAgentId: "main" })),
}));
vi.mock("../../agents/model-selection.js", () => ({
resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5" })),
}));
vi.mock("../../agents/system-prompt-params.js", () => ({
buildSystemPromptParams: vi.fn(() => ({
runtimeInfo: { host: "unknown", os: "unknown", arch: "unknown", node: process.version },
userTimezone: "UTC",
userTime: "12:00 PM",
userTimeFormat: "12h",
})),
}));
vi.mock("../../agents/system-prompt.js", () => ({
buildAgentSystemPrompt: vi.fn(() => "system prompt"),
}));
vi.mock("../../agents/tool-summaries.js", () => ({
buildToolSummaryMap: vi.fn(() => ({})),
}));
vi.mock("../../infra/skills-remote.js", () => ({
getRemoteSkillEligibility: vi.fn(() => false),
}));
vi.mock("../../tts/tts.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
}));
import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js";
function makeParams(): HandleCommandsParams {
return {
ctx: {
SessionKey: "agent:main:default",
},
cfg: {},
command: {
surface: "telegram",
channel: "telegram",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
rawBodyNormalized: "/context",
commandBodyNormalized: "/context",
},
directives: {},
elevated: {
enabled: true,
allowed: true,
failures: [],
},
agentId: "main",
sessionEntry: {
sessionId: "session-1",
groupId: "group-1",
groupChannel: "#general",
space: "guild-1",
spawnedBy: "agent:parent",
},
sessionKey: "agent:main:default",
workspaceDir: "/tmp/workspace",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
provider: "openai",
model: "gpt-5.4",
contextTokens: 0,
isGroup: false,
} as unknown as HandleCommandsParams;
}
describe("resolveCommandsSystemPromptBundle", () => {
beforeEach(() => {
createOpenClawCodingToolsMock.mockClear();
createOpenClawCodingToolsMock.mockReturnValue([]);
});
it("opts command tool builds into gateway subagent binding", async () => {
await resolveCommandsSystemPromptBundle(makeParams());
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
sessionKey: "agent:main:default",
workspaceDir: "/tmp/workspace",
messageProvider: "telegram",
}),
);
});
});

View File

@@ -57,6 +57,7 @@ export async function resolveCommandsSystemPromptBundle(
agentId: params.agentId,
workspaceDir,
sessionKey: params.sessionKey,
allowGatewaySubagentBinding: true,
messageProvider: params.command.channel,
groupId: params.sessionEntry?.groupId ?? undefined,
groupChannel: params.sessionEntry?.groupChannel ?? undefined,

View File

@@ -599,6 +599,7 @@ describe("/compact command", () => {
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:main",
allowGatewaySubagentBinding: true,
trigger: "manual",
customInstructions: "focus on decisions",
messageChannel: "whatsapp",

View File

@@ -287,10 +287,12 @@ describe("createFollowupRunner bootstrap warning dedupe", () => {
const call = runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0] as
| {
allowGatewaySubagentBinding?: boolean;
bootstrapPromptWarningSignaturesSeen?: string[];
bootstrapPromptWarningSignature?: string;
}
| undefined;
expect(call?.allowGatewaySubagentBinding).toBe(true);
expect(call?.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]);
expect(call?.bootstrapPromptWarningSignature).toBe("sig-b");
});

View File

@@ -171,6 +171,7 @@ export function createFollowupRunner(params: {
let attemptCompactionCount = 0;
try {
const result = await runEmbeddedPiAgent({
allowGatewaySubagentBinding: true,
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
agentId: queued.run.agentId,

View File

@@ -220,6 +220,7 @@ export async function handleInlineActions(params: {
agentDir,
workspaceDir,
config: cfg,
allowGatewaySubagentBinding: true,
});
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);

View File

@@ -59,7 +59,7 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["telegram"],
onlyPluginIds: [],
}),
);
});
@@ -85,7 +85,7 @@ describe("ensurePluginRegistryLoaded", () => {
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ onlyPluginIds: ["telegram"] }),
expect.objectContaining({ onlyPluginIds: [] }),
);
expect(mocks.loadOpenClawPlugins).toHaveBeenNthCalledWith(
2,

View File

@@ -81,6 +81,7 @@ async function runFastModeCase(params: {
provider: "openai",
model: "gpt-4",
fastMode: params.expectedFastMode,
allowGatewaySubagentBinding: true,
});
}

View File

@@ -622,6 +622,9 @@ export async function runCronIsolatedAgentTurn(params: {
sessionKey: agentSessionKey,
agentId,
trigger: "cron",
// Cron runs execute inside the gateway process and need the same
// explicit subagent late-binding as other gateway-owned runners.
allowGatewaySubagentBinding: true,
// Cron jobs are trusted local automation, so isolated runs should
// inherit owner-only tooling like local `openclaw agent` runs.
senderIsOwner: true,

View File

@@ -249,6 +249,9 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse {
config: cfg,
cache: true,
workspaceDir,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
logger: {
info: () => {},
warn: () => {},

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolvePluginTools } from "../../plugins/tools.js";
import { ErrorCodes } from "../protocol/index.js";
import { toolsCatalogHandlers } from "./tools-catalog.js";
@@ -117,4 +118,16 @@ describe("tools.catalog handler", () => {
optional: true,
});
});
it("opts plugin tool catalog loads into gateway subagent binding", async () => {
const { invoke } = createInvokeParams({});
await invoke();
expect(vi.mocked(resolvePluginTools)).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
}),
);
});
});

View File

@@ -85,6 +85,7 @@ function buildPluginGroups(params: {
existingToolNames: params.existingToolNames,
toolAllowlist: ["group:plugins"],
suppressNameConflicts: true,
allowGatewaySubagentBinding: true,
});
const groups = new Map<string, ToolCatalogGroup>();
for (const tool of pluginTools) {

View File

@@ -52,7 +52,9 @@ async function importServerPluginsModule(): Promise<ServerPluginsModule> {
return import("./server-plugins.js");
}
function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntime["subagent"] {
async function createSubagentRuntime(
serverPlugins: ServerPluginsModule,
): Promise<PluginRuntime["subagent"]> {
const log = {
info: vi.fn(),
warn: vi.fn(),
@@ -68,17 +70,20 @@ function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntim
baseMethods: [],
});
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
| { runtimeOptions?: { subagent?: PluginRuntime["subagent"] } }
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
| undefined;
if (!call?.runtimeOptions?.subagent) {
throw new Error("Expected loadGatewayPlugins to provide subagent runtime");
if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) {
throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding");
}
return call.runtimeOptions.subagent;
const runtimeModule = await import("../plugins/runtime/index.js");
return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent;
}
beforeEach(() => {
beforeEach(async () => {
loadOpenClawPlugins.mockReset();
handleGatewayRequest.mockReset();
const runtimeModule = await import("../plugins/runtime/index.js");
runtimeModule.clearGatewaySubagentRuntime();
handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
switch (opts.req.method) {
case "agent":
@@ -99,7 +104,9 @@ beforeEach(() => {
});
});
afterEach(() => {
afterEach(async () => {
const runtimeModule = await import("../plugins/runtime/index.js");
runtimeModule.clearGatewaySubagentRuntime();
vi.resetModules();
});
@@ -156,8 +163,14 @@ describe("loadGatewayPlugins", () => {
baseMethods: [],
});
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0];
const subagent = call?.runtimeOptions?.subagent;
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
| undefined;
expect(call?.runtimeOptions?.allowGatewaySubagentBinding).toBe(true);
const runtimeModule = await import("../plugins/runtime/index.js");
const subagent = runtimeModule.createPluginRuntime({
allowGatewaySubagentBinding: true,
}).subagent;
expect(typeof subagent?.getSessionMessages).toBe("function");
expect(typeof subagent?.getSession).toBe("function");
});
@@ -223,7 +236,7 @@ describe("loadGatewayPlugins", () => {
test("shares fallback context across module reloads for existing runtimes", async () => {
const first = await importServerPluginsModule();
const runtime = createSubagentRuntime(first);
const runtime = await createSubagentRuntime(first);
const staleContext = createTestContext("stale");
first.setFallbackGatewayContext(staleContext);
@@ -241,7 +254,7 @@ describe("loadGatewayPlugins", () => {
test("uses updated fallback context after context replacement", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = createSubagentRuntime(serverPlugins);
const runtime = await createSubagentRuntime(serverPlugins);
const firstContext = createTestContext("before-restart");
const secondContext = createTestContext("after-restart");
@@ -256,7 +269,7 @@ describe("loadGatewayPlugins", () => {
test("reflects fallback context object mutation at dispatch time", async () => {
const serverPlugins = await importServerPluginsModule();
const runtime = createSubagentRuntime(serverPlugins);
const runtime = await createSubagentRuntime(serverPlugins);
const context = { marker: "before-mutation" } as GatewayRequestContext & {
marker: string;
};

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { loadConfig } from "../config/config.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import { setGatewaySubagentRuntime } from "../plugins/runtime/index.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js";
import type { ErrorShape } from "./protocol/index.js";
@@ -175,6 +176,13 @@ export function loadGatewayPlugins(params: {
preferSetupRuntimeForChannelPlugins?: boolean;
logDiagnostics?: boolean;
}) {
// Set the process-global gateway subagent runtime BEFORE loading plugins.
// Gateway-owned registries may already exist from schema loads, so the
// gateway path opts those runtimes into late binding rather than changing
// the default subagent behavior for every plugin runtime in the process.
const gatewaySubagent = createGatewaySubagentRuntime();
setGatewaySubagentRuntime(gatewaySubagent);
const pluginRegistry = loadOpenClawPlugins({
config: params.cfg,
workspaceDir: params.workspaceDir,
@@ -186,7 +194,7 @@ export function loadGatewayPlugins(params: {
},
coreGatewayHandlers: params.coreGatewayHandlers,
runtimeOptions: {
subagent: createGatewaySubagentRuntime(),
allowGatewaySubagentBinding: true,
},
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
});

View File

@@ -380,6 +380,14 @@ describe("POST /tools/invoke", () => {
);
});
it("opts direct gateway tool invocation into gateway subagent binding", async () => {
allowAgentsListForMain();
const res = await invokeAgentsListAuthed({ sessionKey: "main" });
expect(res.status).toBe(200);
expect(lastCreateOpenClawToolsContext?.allowGatewaySubagentBinding).toBe(true);
});
it("blocks tool execution when before_tool_call rejects the invoke", async () => {
setMainAllowedTools({ allow: ["tools_invoke_test"] });
hookMocks.runBeforeToolCallHook.mockResolvedValueOnce({

View File

@@ -254,6 +254,7 @@ export async function handleToolsInvokeHttpRequest(
agentAccountId: accountId,
agentTo,
agentThreadId,
allowGatewaySubagentBinding: true,
// HTTP callers consume tool output directly; preserve raw media invoke payloads.
allowMediaInvokeCommands: true,
config: cfg,

View File

@@ -123,6 +123,9 @@ describe("outbound channel resolution", () => {
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({
config: { autoEnabled: true },
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
getChannelPluginMock.mockReturnValue(undefined);
@@ -131,6 +134,13 @@ describe("outbound channel resolution", () => {
cfg: { channels: {} } as never,
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1);
expect(loadOpenClawPluginsMock).toHaveBeenLastCalledWith({
config: { autoEnabled: true },
workspaceDir: "/tmp/workspace",
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
});
it("bootstraps when the active registry has other channels but not the requested one", async () => {

View File

@@ -54,6 +54,9 @@ function maybeBootstrapChannelPlugin(params: {
loadOpenClawPlugins({
config: autoEnabled,
workspaceDir,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
} catch {
// Allow a follow-up resolution attempt if bootstrap failed transiently.

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),
});