From eeb140b4f08b12037a86220324eee5ff680bc66f Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 16 Mar 2026 14:27:54 -0700 Subject: [PATCH] fix(plugins): late-binding subagent runtime for non-gateway load paths (#46648) Merged via squash. Prepared head SHA: 44742652c9ac2eec82a6d958fd77f84ba1d29c0a Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + src/acp/translator.session-rate-limit.test.ts | 16 ++- .../openclaw-tools.plugin-context.test.ts | 34 ++++- src/agents/openclaw-tools.ts | 3 + .../pi-embedded-runner/compact.hooks.test.ts | 42 +++++- src/agents/pi-embedded-runner/compact.ts | 5 + .../run.overflow-compaction.mocks.shared.ts | 13 ++ src/agents/pi-embedded-runner/run.ts | 2 + src/agents/pi-embedded-runner/run/attempt.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 2 + .../usage-reporting.test.ts | 33 +++++ src/agents/pi-tools.ts | 3 + src/agents/runtime-plugins.ts | 6 + .../subagent-registry.context-engine.test.ts | 1 + src/agents/subagent-registry.ts | 1 + .../reply/agent-runner-execution.ts | 1 + src/auto-reply/reply/agent-runner-memory.ts | 1 + src/auto-reply/reply/commands-compact.ts | 1 + .../reply/commands-system-prompt.test.ts | 127 ++++++++++++++++++ .../reply/commands-system-prompt.ts | 1 + src/auto-reply/reply/commands.test.ts | 1 + src/auto-reply/reply/followup-runner.test.ts | 2 + src/auto-reply/reply/followup-runner.ts | 1 + .../reply/get-reply-inline-actions.ts | 1 + src/cli/plugin-registry.test.ts | 4 +- src/cron/isolated-agent/run.fast-mode.test.ts | 1 + src/cron/isolated-agent/run.ts | 3 + src/gateway/server-methods/config.ts | 3 + .../server-methods/tools-catalog.test.ts | 13 ++ src/gateway/server-methods/tools-catalog.ts | 1 + src/gateway/server-plugins.test.ts | 37 +++-- src/gateway/server-plugins.ts | 10 +- src/gateway/tools-invoke-http.test.ts | 8 ++ src/gateway/tools-invoke-http.ts | 1 + src/infra/outbound/channel-resolution.test.ts | 10 ++ src/infra/outbound/channel-resolution.ts | 3 + src/plugins/loader.test.ts | 38 ++++++ src/plugins/loader.ts | 9 +- src/plugins/runtime/index.test.ts | 40 +++++- src/plugins/runtime/index.ts | 79 ++++++++++- src/plugins/tools.optional.test.ts | 18 +++ src/plugins/tools.ts | 6 + 42 files changed, 555 insertions(+), 28 deletions(-) create mode 100644 src/auto-reply/reply/commands-system-prompt.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bd421888c..3534d41f0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index 3e3f254d0ee..55446550f9f 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -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({ diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index 1cf9116a98e..6a20a127898 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 25b5cae0f59..32bd92f4207 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -80,6 +80,8 @@ export function createOpenClawTools( spawnWorkspaceDir?: string; /** Callback invoked when sessions_yield tool is called. */ onYield?: (message: string) => Promise | 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]; diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 0a864236b81..54ad50539e3 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 67c5b8184b2..ba001a6746a 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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); diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 53e73e6246d..8451ef54994 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -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", () => ({ diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 6ecf34ed93e..3f41357f0e5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index bb2cad960bd..64af7b7ffd5 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index ba69d991dd9..3aef4fb2752 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/usage-reporting.test.ts b/src/agents/pi-embedded-runner/usage-reporting.test.ts index ebab56a841b..7c29c5f99cf 100644 --- a/src/agents/pi-embedded-runner/usage-reporting.test.ts +++ b/src/agents/pi-embedded-runner/usage-reporting.test.ts @@ -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, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 9c7aafbd56e..b8be63f65e5 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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 = diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index ace53258e0f..0bf395b505c 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -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, }); } diff --git a/src/agents/subagent-registry.context-engine.test.ts b/src/agents/subagent-registry.context-engine.test.ts index 59eea1bd4c7..bb9916b7cfd 100644 --- a/src/agents/subagent-registry.context-engine.test.ts +++ b/src/agents/subagent-registry.context-engine.test.ts @@ -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); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index c1cab60dd82..d36e20bf291 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -322,6 +322,7 @@ async function notifyContextEngineSubagentEnded(params: { ensureRuntimePluginsLoaded({ config: cfg, workspaceDir: params.workspaceDir, + allowGatewaySubagentBinding: true, }); ensureContextEnginesInitialized(); const engine = await resolveContextEngine(cfg); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9ebc239f7ff..5c9b78c208f 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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: diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index d52c6d05761..267326a7e20 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -494,6 +494,7 @@ export async function runMemoryFlushIfNeeded(params: { ...embeddedContext, ...senderContext, ...runBaseParams, + allowGatewaySubagentBinding: true, trigger: "memory", memoryFlushWritePath, prompt: resolveMemoryFlushPromptForRun({ diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 1533bb24393..9c3c9f28c29 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -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, diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts new file mode 100644 index 00000000000..09499fc3181 --- /dev/null +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -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", + }), + ); + }); +}); diff --git a/src/auto-reply/reply/commands-system-prompt.ts b/src/auto-reply/reply/commands-system-prompt.ts index 4197e7b2491..18b2e337d72 100644 --- a/src/auto-reply/reply/commands-system-prompt.ts +++ b/src/auto-reply/reply/commands-system-prompt.ts @@ -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, diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 0f2853aab98..5ed9919b7e8 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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", diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index c8e33397a2a..fa7f0fb8637 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -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"); }); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index fe90d56433c..339883e730b 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 73983cfdc49..44d006a5ccb 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -220,6 +220,7 @@ export async function handleInlineActions(params: { agentDir, workspaceDir, config: cfg, + allowGatewaySubagentBinding: true, }); const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner); diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts index f9751d5fed8..336c720dfdb 100644 --- a/src/cli/plugin-registry.test.ts +++ b/src/cli/plugin-registry.test.ts @@ -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, diff --git a/src/cron/isolated-agent/run.fast-mode.test.ts b/src/cron/isolated-agent/run.fast-mode.test.ts index abe50ea5554..1a176709a04 100644 --- a/src/cron/isolated-agent/run.fast-mode.test.ts +++ b/src/cron/isolated-agent/run.fast-mode.test.ts @@ -81,6 +81,7 @@ async function runFastModeCase(params: { provider: "openai", model: "gpt-4", fastMode: params.expectedFastMode, + allowGatewaySubagentBinding: true, }); } diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 9f3f28584e3..78f045d03cf 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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, diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 6e6cf9e92e3..977a59f00b5 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -249,6 +249,9 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse { config: cfg, cache: true, workspaceDir, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, logger: { info: () => {}, warn: () => {}, diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 70fcdcdf85e..b806bbdd14d 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index 27f488822a3..2eec921c4c0 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -85,6 +85,7 @@ function buildPluginGroups(params: { existingToolNames: params.existingToolNames, toolAllowlist: ["group:plugins"], suppressNameConflicts: true, + allowGatewaySubagentBinding: true, }); const groups = new Map(); for (const tool of pluginTools) { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 489ce365d61..8e0d97a1580 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -52,7 +52,9 @@ async function importServerPluginsModule(): Promise { return import("./server-plugins.js"); } -function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntime["subagent"] { +async function createSubagentRuntime( + serverPlugins: ServerPluginsModule, +): Promise { 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; }; diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 4bcf8fa8d08..587aa71dc41 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -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, }); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index f47e80a9bf6..96ede78ef00 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -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({ diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 0cccafce999..80b6dc37733 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -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, diff --git a/src/infra/outbound/channel-resolution.test.ts b/src/infra/outbound/channel-resolution.test.ts index 3d8f8c4fbdd..30480fd0046 100644 --- a/src/infra/outbound/channel-resolution.test.ts +++ b/src/infra/outbound/channel-resolution.test.ts @@ -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 () => { diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index c39ff8bb210..15372daa2a1 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -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. diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 567568c3a1f..325290cded2 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 67432910a62..3d6297f90d2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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) { diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 4759b5813d7..dfca1cfaf4a 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -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" }); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 8d590899bf4..d94825062cd 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -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 }, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 20e68f0ca66..80c41858733 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -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, + }, + }), + ); + }); }); diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index ebf96ec6a4c..9a1142a8306 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -47,6 +47,7 @@ export function resolvePluginTools(params: { existingToolNames?: Set; 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), });