From 74275c5f768d6c3109ab7d022c45c44f8d560756 Mon Sep 17 00:00:00 2001 From: user <14040213+LivingGhost@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:52:12 +0900 Subject: [PATCH] Plugins: fix subagent dispatch and runtime state management --- src/agents/runtime-plugins.test.ts | 31 ++ src/agents/runtime-plugins.ts | 1 + src/auto-reply/templating.ts | 2 + src/commands/doctor-workspace-status.ts | 5 +- src/commands/status.scan.test.ts | 9 +- src/gateway/server-plugins.test.ts | 263 ++++++++++++++- src/gateway/server-plugins.ts | 206 +++++++++--- src/plugins/loader.test.ts | 299 +++++++++++++++++- src/plugins/loader.ts | 22 +- src/plugins/registry.ts | 27 +- src/plugins/runtime/index.test.ts | 30 ++ src/plugins/runtime/index.ts | 2 + src/plugins/runtime/shared-runtime-options.ts | 43 +++ src/plugins/runtime/types.ts | 26 ++ src/plugins/status.ts | 23 +- src/plugins/tools.optional.test.ts | 14 + src/plugins/tools.ts | 1 + src/plugins/types.ts | 1 + .../helpers/extensions/plugin-runtime-mock.ts | 2 + 19 files changed, 921 insertions(+), 86 deletions(-) create mode 100644 src/agents/runtime-plugins.test.ts create mode 100644 src/plugins/runtime/shared-runtime-options.ts diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts new file mode 100644 index 00000000000..2eee1094e73 --- /dev/null +++ b/src/agents/runtime-plugins.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins, +})); + +import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; + +afterEach(() => { + loadOpenClawPlugins.mockReset(); +}); + +describe("ensureRuntimePluginsLoaded", () => { + test("opts into shared runtime inheritance", () => { + const config = { plugins: { enabled: true } }; + + ensureRuntimePluginsLoaded({ + config, + workspaceDir: "/tmp/workspace", + }); + + expect(loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + inheritSharedRuntimeOptions: true, + }), + ); + }); +}); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 0bf395b505c..8d262899421 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -20,5 +20,6 @@ export function ensureRuntimePluginsLoaded(params: { allowGatewaySubagentBinding: true, } : undefined, + inheritSharedRuntimeOptions: true, }); } diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 601fa6891bf..e1823c9ed46 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -123,6 +123,8 @@ export type MsgContext = { /** Explicit owner allowlist overrides (trusted, configuration-derived). */ OwnerAllowFrom?: Array; SenderName?: string; + /** Provider-managed account id when the inbound sender is one of our configured bot accounts. */ + SenderManagedAccountId?: string; SenderId?: string; SenderUsername?: string; SenderTag?: string; diff --git a/src/commands/doctor-workspace-status.ts b/src/commands/doctor-workspace-status.ts index f0069ab0bd5..34264528885 100644 --- a/src/commands/doctor-workspace-status.ts +++ b/src/commands/doctor-workspace-status.ts @@ -66,10 +66,7 @@ export function noteWorkspaceStatus(cfg: OpenClawConfig) { const compatibilityWarnings = buildPluginCompatibilityWarnings({ config: cfg, workspaceDir, - report: { - workspaceDir, - ...pluginRegistry, - }, + report: pluginRegistry, }); if (compatibilityWarnings.length > 0) { note(compatibilityWarnings.map((line) => `- ${line}`).join("\n"), "Plugin compatibility"); diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 1098b3d9bc3..36dcf92711d 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -15,7 +15,6 @@ const mocks = vi.hoisted(() => ({ probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), - buildPluginCompatibilityNotices: vi.fn(() => []), })); beforeEach(() => { @@ -101,10 +100,6 @@ vi.mock("../cli/plugin-registry.js", () => ({ ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, })); -vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices: mocks.buildPluginCompatibilityNotices, -})); - import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { @@ -416,14 +411,14 @@ describe("scanStatus", () => { session: {}, plugins: { enabled: false }, gateway: {}, - channels: { telegram: { enabled: false } }, + channels: { telegram: { enabled: false, botToken: "token" } }, }); mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ resolvedConfig: { session: {}, plugins: { enabled: false }, gateway: {}, - channels: { telegram: { enabled: false } }, + channels: { telegram: { enabled: false, botToken: "token" } }, }, diagnostics: [], }); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7bb0fb20f06..78c7fd2290f 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; +import { + clearSharedPluginRuntimeOptions, + getSharedPluginRuntimeOptions, + setSharedPluginRuntimeOptions, +} from "../plugins/runtime/shared-runtime-options.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginDiagnostic } from "../plugins/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; @@ -29,14 +34,15 @@ vi.mock("./server-methods.js", () => ({ })); vi.mock("../channels/registry.js", () => ({ - CHAT_CHANNEL_ORDER: [], - CHANNEL_IDS: [], + CHAT_CHANNEL_ORDER: ["telegram", "discord", "slack"], + CHANNEL_IDS: ["telegram", "discord", "slack"], listChatChannels: () => [], listChatChannelAliases: () => [], getChatChannelMeta: () => null, normalizeChatChannelId: () => null, normalizeChannelId: () => null, - normalizeAnyChannelId: () => null, + normalizeAnyChannelId: (raw?: string | null) => + typeof raw === "string" && raw.trim().length > 0 ? raw.trim().toLowerCase() : null, formatChannelPrimerLine: () => "", formatChannelSelectionLine: () => "", })); @@ -92,6 +98,24 @@ function getLastDispatchedClientScopes(): string[] { return Array.isArray(scopes) ? scopes : []; } +function getLastDispatchedRequest(): + | { method: string; params?: Record } + | undefined { + const call = handleGatewayRequest.mock.calls.at(-1)?.[0]; + const req = call?.req; + if (!req) { + return undefined; + } + const params = + "params" in req && req.params != null && typeof req.params === "object" + ? (req.params as Record) + : undefined; + return { + method: req.method, + params, + }; +} + async function loadTestModules() { serverPluginsModule = await import("./server-plugins.js"); runtimeModule = await import("../plugins/runtime/index.js"); @@ -99,6 +123,10 @@ async function loadTestModules() { methodScopesModule = await import("./method-scopes.js"); } +async function importServerPluginsModule(): Promise { + return import("./server-plugins.js"); +} + async function createSubagentRuntime( serverPlugins: ServerPluginsModule, cfg: Record = {}, @@ -144,10 +172,21 @@ beforeEach(() => { handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { switch (opts.req.method) { case "agent": + case "agent.enqueue": opts.respond(true, { runId: "run-1" }); return; case "agent.wait": - opts.respond(true, { status: "ok" }); + opts.respond(true, { + status: "ok", + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call-1", + name: "emit_structured_result", + arguments: '{"entries":[]}', + }, + ], + }); return; case "sessions.get": opts.respond(true, { messages: [] }); @@ -163,6 +202,8 @@ beforeEach(() => { afterEach(() => { runtimeModule.clearGatewaySubagentRuntime(); + clearSharedPluginRuntimeOptions(); + vi.resetModules(); }); describe("loadGatewayPlugins", () => { @@ -407,8 +448,8 @@ describe("loadGatewayPlugins", () => { expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); - test("allows fallback session reads with synthetic write scope", async () => { - const serverPlugins = serverPluginsModule; + test("allows fallback session reads with synthetic read scope", async () => { + const serverPlugins = await importServerPluginsModule(); const runtime = await createSubagentRuntime(serverPlugins); serverPlugins.setFallbackGatewayContext(createTestContext("synthetic-session-read")); @@ -433,7 +474,7 @@ describe("loadGatewayPlugins", () => { messages: [{ id: "m-1" }], }); - expect(getLastDispatchedClientScopes()).toEqual(["operator.write"]); + expect(getLastDispatchedClientScopes()).toEqual(["operator.read"]); expect(getLastDispatchedClientScopes()).not.toContain("operator.admin"); }); @@ -531,6 +572,81 @@ describe("loadGatewayPlugins", () => { expect(log.error).not.toHaveBeenCalled(); expect(log.info).not.toHaveBeenCalled(); }); + + test("publishes shared runtime options for later plugin reloads", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockReturnValue(createRegistry([])); + + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + coreGatewayHandlers: {}, + baseMethods: [], + }); + + expect(typeof getSharedPluginRuntimeOptions()?.subagent?.run).toBe("function"); + }); + + test("rolls back shared runtime options when plugin loading fails", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + loadOpenClawPlugins.mockImplementation(() => { + throw new Error("plugin load failed"); + }); + + expect(() => + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + coreGatewayHandlers: {}, + baseMethods: [], + }), + ).toThrow("plugin load failed"); + + expect(getSharedPluginRuntimeOptions()).toBeUndefined(); + }); + + test("restores previous shared runtime options when plugin loading fails", async () => { + const { loadGatewayPlugins } = await importServerPluginsModule(); + const previousRuntime = { + subagent: { + run: vi.fn(), + }, + } as unknown as NonNullable>; + setSharedPluginRuntimeOptions(previousRuntime); + loadOpenClawPlugins.mockImplementation(() => { + throw new Error("plugin load failed"); + }); + + expect(() => + loadGatewayPlugins({ + cfg: {}, + workspaceDir: "/tmp", + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + coreGatewayHandlers: {}, + baseMethods: [], + }), + ).toThrow("plugin load failed"); + + expect(getSharedPluginRuntimeOptions()).toBe(previousRuntime); + }); + test("shares fallback context across module reloads for existing runtimes", async () => { const first = serverPluginsModule; const runtime = await createSubagentRuntime(first); @@ -579,4 +695,137 @@ describe("loadGatewayPlugins", () => { | undefined; expect(dispatched?.marker).toBe("after-mutation"); }); + + test("mints idempotency keys for plugin subagent requests when absent", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("idempotency-generated")); + + await runtime.run({ sessionKey: "s-run", message: "hello" }); + const runRequest = getLastDispatchedRequest(); + expect(runRequest?.method).toBe("agent"); + expect(runRequest?.params).toMatchObject({ + sessionKey: "s-run", + message: "hello", + deliver: false, + }); + expect(runRequest?.params?.idempotencyKey).toEqual( + expect.stringMatching(/^plugin-subagent:agent:s-run:/), + ); + + await runtime.enqueue({ sessionKey: "s-enqueue", message: "queued" }); + const enqueueRequest = getLastDispatchedRequest(); + expect(enqueueRequest?.method).toBe("agent.enqueue"); + expect(enqueueRequest?.params).toMatchObject({ + sessionKey: "s-enqueue", + message: "queued", + deliver: false, + }); + expect(enqueueRequest?.params?.idempotencyKey).toEqual( + expect.stringMatching(/^plugin-subagent:agent\.enqueue:s-enqueue:/), + ); + }); + + test("preserves caller-provided idempotency keys for plugin subagent requests", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("idempotency-preserved")); + + await runtime.run({ + sessionKey: "s-run", + message: "hello", + idempotencyKey: "plugin-run-idem", + }); + expect(getLastDispatchedRequest()?.params?.idempotencyKey).toBe("plugin-run-idem"); + + await runtime.enqueue({ + sessionKey: "s-enqueue", + message: "queued", + idempotencyKey: "plugin-enqueue-idem", + }); + expect(getLastDispatchedRequest()?.params?.idempotencyKey).toBe("plugin-enqueue-idem"); + }); + + test("forwards structured plugin subagent options to gateway agent methods", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + serverPlugins.setFallbackGatewayContext(createTestContext("structured-output")); + + await runtime.run({ + sessionKey: "s-structured", + message: "extract memories", + disableTools: true, + clientTools: [ + { + type: "function", + function: { + name: "emit_structured_result", + description: "Return a structured result payload.", + parameters: { + type: "object", + properties: { + entries: { + type: "array", + }, + }, + }, + }, + }, + ], + streamParams: { + toolChoice: { + type: "function", + function: { + name: "emit_structured_result", + }, + }, + }, + }); + + const dispatched = getLastDispatchedRequest(); + expect(dispatched?.method).toBe("agent"); + expect(dispatched?.params).toMatchObject({ + sessionKey: "s-structured", + message: "extract memories", + disableTools: true, + clientTools: [ + { + type: "function", + function: expect.objectContaining({ + name: "emit_structured_result", + }), + }, + ], + streamParams: { + toolChoice: { + type: "function", + function: { + name: "emit_structured_result", + }, + }, + }, + }); + }); + + test("returns pending tool calls from gateway agent.wait", async () => { + const serverPlugins = await importServerPluginsModule(); + const runtime = await createSubagentRuntime(serverPlugins); + + const result = await runtime.waitForRun({ + runId: "run-1", + timeoutMs: 1_000, + }); + + expect(result).toEqual({ + status: "ok", + stopReason: "tool_calls", + pendingToolCalls: [ + { + id: "call-1", + name: "emit_structured_result", + arguments: '{"entries":[]}', + }, + ], + }); + }); }); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 071819be73e..987a59ec038 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -6,8 +6,13 @@ import { normalizePluginsConfig } from "../plugins/config-state.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { setGatewaySubagentRuntime } from "../plugins/runtime/index.js"; +import { + clearSharedPluginRuntimeOptions, + getSharedPluginRuntimeOptions, + setSharedPluginRuntimeOptions, +} from "../plugins/runtime/shared-runtime-options.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; -import { ADMIN_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; +import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; import type { ErrorShape } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; @@ -155,10 +160,7 @@ function authorizeFallbackModelOverride(params: { if (!policy?.allowModelOverride) { return { allowed: false, - reason: - `plugin "${pluginId}" is not trusted for fallback provider/model override requests. ` + - "See https://docs.openclaw.ai/tools/plugin#runtime-helpers and search for: " + - "plugins.entries..subagent.allowModelOverride", + reason: `plugin "${pluginId}" is not trusted for fallback provider/model override requests.`, }; } if (policy.allowAnyModel) { @@ -261,6 +263,25 @@ async function dispatchGatewayMethod( } let result: { ok: boolean; payload?: unknown; error?: ErrorShape } | undefined; + const resolvedClient = scope?.client + ? { + ...scope.client, + connect: { + ...scope.client.connect, + scopes: [ + ...(Array.isArray(scope.client.connect?.scopes) ? scope.client.connect.scopes : []), + ...(options?.syntheticScopes ?? []), + ], + }, + internal: { + ...scope.client.internal, + ...(options?.allowSyntheticModelOverride === true ? { allowModelOverride: true } : {}), + }, + } + : createSyntheticOperatorClient({ + allowModelOverride: options?.allowSyntheticModelOverride === true, + scopes: options?.syntheticScopes, + }); await handleGatewayRequest({ req: { type: "req", @@ -268,12 +289,7 @@ async function dispatchGatewayMethod( method, params, }, - client: - scope?.client ?? - createSyntheticOperatorClient({ - allowModelOverride: options?.allowSyntheticModelOverride === true, - scopes: options?.syntheticScopes, - }), + client: resolvedClient, isWebchatConnect, respond: (ok, payload, error) => { if (!result) { @@ -292,12 +308,30 @@ async function dispatchGatewayMethod( return result.payload as T; } +function resolvePluginSubagentIdempotencyKey(params: { + idempotencyKey?: string; + sessionKey: string; + method: "agent" | "agent.enqueue"; +}): string { + const provided = typeof params.idempotencyKey === "string" ? params.idempotencyKey.trim() : ""; + if (provided) { + return provided; + } + return `plugin-subagent:${params.method}:${params.sessionKey}:${randomUUID()}`; +} + function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { const getSessionMessages: PluginRuntime["subagent"]["getSessionMessages"] = async (params) => { - const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>("sessions.get", { - key: params.sessionKey, - ...(params.limit != null && { limit: params.limit }), - }); + const payload = await dispatchGatewayMethod<{ messages?: unknown[] }>( + "sessions.get", + { + key: params.sessionKey, + ...(params.limit != null && { limit: params.limit }), + }, + { + syntheticScopes: [READ_SCOPE], + }, + ); return { messages: Array.isArray(payload?.messages) ? payload.messages : [] }; }; @@ -308,7 +342,7 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { const hasRequestScopeClient = Boolean(scope?.client); let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null); let allowSyntheticModelOverride = false; - if (overrideRequested && !allowOverride && !hasRequestScopeClient) { + if (overrideRequested && !allowOverride) { const fallbackAuth = authorizeFallbackModelOverride({ pluginId: scope?.pluginId, provider: params.provider, @@ -320,23 +354,29 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { allowOverride = true; allowSyntheticModelOverride = true; } - if (overrideRequested && !allowOverride) { - throw new Error("provider/model override is not authorized for this plugin subagent run."); - } const payload = await dispatchGatewayMethod<{ runId?: string }>( "agent", { sessionKey: params.sessionKey, message: params.message, deliver: params.deliver ?? false, + idempotencyKey: resolvePluginSubagentIdempotencyKey({ + idempotencyKey: params.idempotencyKey, + sessionKey: params.sessionKey, + method: "agent", + }), ...(allowOverride && params.provider && { provider: params.provider }), ...(allowOverride && params.model && { model: params.model }), ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), ...(params.lane && { lane: params.lane }), + ...(params.clientTools && { clientTools: params.clientTools }), + ...(params.disableTools === true && { disableTools: true }), + ...(params.streamParams && { streamParams: params.streamParams }), ...(params.idempotencyKey && { idempotencyKey: params.idempotencyKey }), }, { allowSyntheticModelOverride, + syntheticScopes: [WRITE_SCOPE], }, ); const runId = payload?.runId; @@ -345,13 +385,83 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { } return { runId }; }, + async enqueue(params) { + const scope = getPluginRuntimeGatewayRequestScope(); + const overrideRequested = Boolean(params.provider || params.model); + const hasRequestScopeClient = Boolean(scope?.client); + let allowOverride = hasRequestScopeClient && canClientUseModelOverride(scope?.client ?? null); + let allowSyntheticModelOverride = false; + if (overrideRequested && !allowOverride) { + const fallbackAuth = authorizeFallbackModelOverride({ + pluginId: scope?.pluginId, + provider: params.provider, + model: params.model, + }); + if (!fallbackAuth.allowed) { + throw new Error(fallbackAuth.reason); + } + allowOverride = true; + allowSyntheticModelOverride = true; + } + const payload = await dispatchGatewayMethod<{ runId?: string }>( + "agent.enqueue", + { + sessionKey: params.sessionKey, + message: params.message, + deliver: params.deliver ?? false, + idempotencyKey: resolvePluginSubagentIdempotencyKey({ + idempotencyKey: params.idempotencyKey, + sessionKey: params.sessionKey, + method: "agent.enqueue", + }), + ...(allowOverride && params.provider && { provider: params.provider }), + ...(allowOverride && params.model && { model: params.model }), + ...(params.extraSystemPrompt && { extraSystemPrompt: params.extraSystemPrompt }), + ...(params.lane && { lane: params.lane }), + }, + { + allowSyntheticModelOverride, + syntheticScopes: [WRITE_SCOPE], + }, + ); + const runId = payload?.runId; + if (typeof runId !== "string" || !runId) { + throw new Error("Gateway agent.enqueue method returned an invalid runId."); + } + return { runId }; + }, + async abort(params) { + const payload = await dispatchGatewayMethod<{ aborted?: boolean }>( + "agent.abort", + { + runId: params.runId, + ...(params.sessionKey && { sessionKey: params.sessionKey }), + }, + { + syntheticScopes: [ADMIN_SCOPE], + }, + ); + return { aborted: payload?.aborted === true }; + }, async waitForRun(params) { - const payload = await dispatchGatewayMethod<{ status?: string; error?: string }>( + const payload = await dispatchGatewayMethod<{ + status?: string; + error?: string; + stopReason?: string; + pendingToolCalls?: Array<{ + id: string; + name: string; + arguments: string; + }>; + }>( "agent.wait", { runId: params.runId, ...(params.timeoutMs != null && { timeoutMs: params.timeoutMs }), }, + { + syntheticScopes: [WRITE_SCOPE], + }, ); const status = payload?.status; if (status !== "ok" && status !== "error" && status !== "timeout") { @@ -360,6 +470,11 @@ function createGatewaySubagentRuntime(): PluginRuntime["subagent"] { return { status, ...(typeof payload?.error === "string" && payload.error && { error: payload.error }), + ...(typeof payload?.stopReason === "string" && + payload.stopReason && { stopReason: payload.stopReason }), + ...(Array.isArray(payload?.pendingToolCalls) && payload.pendingToolCalls.length > 0 + ? { pendingToolCalls: payload.pendingToolCalls } + : {}), }; }, getSessionMessages, @@ -398,29 +513,42 @@ export function loadGatewayPlugins(params: { logDiagnostics?: boolean; }) { setPluginSubagentOverridePolicies(params.cfg); - // Set the process-global gateway subagent runtime BEFORE loading plugins. + // 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, - logger: { - info: (msg) => params.log.info(msg), - warn: (msg) => params.log.warn(msg), - error: (msg) => params.log.error(msg), - debug: (msg) => params.log.debug(msg), - }, - coreGatewayHandlers: params.coreGatewayHandlers, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, + const gatewaySubagentRuntime = createGatewaySubagentRuntime(); + setGatewaySubagentRuntime(gatewaySubagentRuntime); + const previousSharedRuntimeOptions = getSharedPluginRuntimeOptions(); + setSharedPluginRuntimeOptions({ + subagent: gatewaySubagentRuntime, }); - primeConfiguredBindingRegistry({ cfg: params.cfg }); + let pluginRegistry; + try { + primeConfiguredBindingRegistry({ cfg: params.cfg }); + pluginRegistry = loadOpenClawPlugins({ + config: params.cfg, + workspaceDir: params.workspaceDir, + logger: { + info: (msg) => params.log.info(msg), + warn: (msg) => params.log.warn(msg), + error: (msg) => params.log.error(msg), + debug: (msg) => params.log.debug(msg), + }, + coreGatewayHandlers: params.coreGatewayHandlers, + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins, + }); + } catch (error) { + if (previousSharedRuntimeOptions) { + setSharedPluginRuntimeOptions(previousSharedRuntimeOptions); + } else { + clearSharedPluginRuntimeOptions(); + } + throw error; + } const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 8af6cf927d4..d3f27ede22f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -23,19 +23,24 @@ async function importFreshPluginTestModules() { vi.doUnmock("./hooks.js"); vi.doUnmock("./loader.js"); vi.doUnmock("jiti"); - const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([ - import("./loader.js"), - import("./hook-runner-global.js"), - import("./hooks.js"), - import("./runtime.js"), - import("./registry.js"), - ]); + const [loader, hookRunnerGlobal, hooks, runtime, registry, sharedRuntimeOptions, tools] = + await Promise.all([ + import("./loader.js"), + import("./hook-runner-global.js"), + import("./hooks.js"), + import("./runtime.js"), + import("./registry.js"), + import("./runtime/shared-runtime-options.js"), + import("./tools.js"), + ]); return { ...loader, ...hookRunnerGlobal, ...hooks, ...runtime, ...registry, + ...sharedRuntimeOptions, + ...tools, }; } @@ -44,12 +49,15 @@ const { clearPluginLoaderCache, createHookRunner, createEmptyPluginRegistry, + clearSharedPluginRuntimeOptions, getActivePluginRegistry, getActivePluginRegistryKey, getGlobalHookRunner, loadOpenClawPlugins, resetGlobalHookRunner, + resolvePluginTools, setActivePluginRegistry, + setSharedPluginRuntimeOptions, } = await importFreshPluginTestModules(); type TempPlugin = { dir: string; file: string; id: string }; @@ -704,6 +712,8 @@ function resolvePluginRuntimeModule(params: { afterEach(() => { clearPluginLoaderCache(); resetDiagnosticEventsForTest(); + clearSharedPluginRuntimeOptions(); + resetGlobalHookRunner(); if (prevBundledDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -1244,6 +1254,281 @@ module.exports = { id: "skipped-scoped-only", register() { throw new Error("skip resetGlobalHookRunner(); }); + it("does not reuse cached registries across runtime capability changes", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "runtime-capability-cache", + filename: "runtime-capability-cache.cjs", + body: `module.exports = { + id: "runtime-capability-cache", + register(api) { + api.on("agent_end", async () => { + await api.runtime.subagent.run({ sessionKey: "runtime-capability", message: "hello" }); + }); + }, + };`, + }); + + const options = { + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["runtime-capability-cache"], + }, + }, + }; + + const first = loadOpenClawPlugins(options); + + const subagent = { + run: vi.fn(async () => ({ runId: "run-1" })), + enqueue: vi.fn(async () => ({ runId: "run-1" })), + abort: vi.fn(async () => ({ aborted: true })), + waitForRun: vi.fn(async () => ({ status: "ok" as const })), + getSessionMessages: vi.fn(async () => ({ messages: [] })), + getSession: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => undefined), + }; + + const second = loadOpenClawPlugins({ + ...options, + runtimeOptions: { subagent }, + }); + + expect(second).not.toBe(first); + expect(second.plugins.map((entry) => entry.id)).toEqual(first.plugins.map((entry) => entry.id)); + + const third = loadOpenClawPlugins({ + ...options, + runtimeOptions: { subagent }, + }); + + expect(third).toBe(second); + }); + + it("does not inherit shared runtime options without explicit opt-in", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "shared-runtime-options-no-inherit", + filename: "shared-runtime-options-no-inherit.cjs", + body: `module.exports = { + id: "shared-runtime-options-no-inherit", + register(api) { + api.on("agent_end", async () => { + await api.runtime.subagent.run({ sessionKey: "shared-runtime-no-inherit", message: "hello" }); + }); + }, + };`, + }); + + const subagent = { + run: vi.fn(async () => ({ runId: "run-shared" })), + enqueue: vi.fn(async () => ({ runId: "run-shared" })), + abort: vi.fn(async () => ({ aborted: true })), + waitForRun: vi.fn(async () => ({ status: "ok" as const })), + getSessionMessages: vi.fn(async () => ({ messages: [] })), + getSession: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => undefined), + }; + setSharedPluginRuntimeOptions({ subagent }); + + const registry = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options-no-inherit"], + }, + }, + }); + + const inherited = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + inheritSharedRuntimeOptions: true, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options-no-inherit"], + }, + }, + }); + + expect(inherited).not.toBe(registry); + expect( + loadOpenClawPlugins({ + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options-no-inherit"], + }, + }, + }), + ).toBe(registry); + }); + + it("does not replace the global hook runner when activation is disabled", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "shared-runtime-options-no-global-replace", + filename: "shared-runtime-options-no-global-replace.cjs", + body: `module.exports = { id: "shared-runtime-options-no-global-replace", register() {} };`, + }); + const activeRegistry = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options-no-global-replace"], + }, + }, + }); + const globalHookRunner = getGlobalHookRunner(); + expect(globalHookRunner).not.toBeNull(); + + const previewRegistry = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + inheritSharedRuntimeOptions: true, + activate: false, + cache: false, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options-no-global-replace"], + }, + }, + }); + + expect(getGlobalHookRunner()).toBe(globalHookRunner); + expect(activeRegistry).not.toBe(previewRegistry); + expect(previewRegistry).not.toBe(activeRegistry); + }); + + it("keeps the global hook runner stable while resolving plugin tools", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "tool-resolution-preserves-hook-runner", + filename: "tool-resolution-preserves-hook-runner.cjs", + body: `module.exports = { + id: "tool-resolution-preserves-hook-runner", + register(api) { + api.on("agent_end", async () => { + await api.runtime.subagent.run({ sessionKey: "tool-resolution-preserved", message: "hello" }); + }); + api.registerTool({ + name: "echo", + description: "echo", + parameters: { type: "object", properties: {} }, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }); + }, + };`, + }); + + const subagent = { + run: vi.fn(async () => ({ runId: "run-shared" })), + enqueue: vi.fn(async () => ({ runId: "run-shared" })), + abort: vi.fn(async () => ({ aborted: true })), + waitForRun: vi.fn(async () => ({ status: "ok" as const })), + getSessionMessages: vi.fn(async () => ({ messages: [] })), + getSession: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => undefined), + }; + + const activeRegistry = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + runtimeOptions: { subagent }, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["tool-resolution-preserves-hook-runner"], + }, + }, + }); + const globalHookRunner = getGlobalHookRunner(); + + expect(globalHookRunner).not.toBeNull(); + + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + enabled: true, + load: { paths: [plugin.file] }, + allow: ["tool-resolution-preserves-hook-runner"], + }, + }, + workspaceDir: plugin.dir, + } as never, + }); + + expect(tools).toHaveLength(1); + expect(getGlobalHookRunner()).toBe(globalHookRunner); + expect(activeRegistry).not.toBeNull(); + }); + + it("uses shared runtime options when explicit runtime options are absent and inheritance is enabled", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "shared-runtime-options", + filename: "shared-runtime-options.cjs", + body: `module.exports = { + id: "shared-runtime-options", + register(api) { + api.on("agent_end", async () => { + await api.runtime.subagent.run({ sessionKey: "shared-runtime", message: "hello" }); + }); + }, + };`, + }); + + const subagent = { + run: vi.fn(async () => ({ runId: "run-shared" })), + enqueue: vi.fn(async () => ({ runId: "run-shared" })), + abort: vi.fn(async () => ({ aborted: true })), + waitForRun: vi.fn(async () => ({ status: "ok" as const })), + getSessionMessages: vi.fn(async () => ({ messages: [] })), + getSession: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => undefined), + }; + setSharedPluginRuntimeOptions({ subagent }); + + const registry = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + inheritSharedRuntimeOptions: true, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options"], + }, + }, + }); + + const inheritedAgain = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + inheritSharedRuntimeOptions: true, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options"], + }, + }, + }); + const plain = loadOpenClawPlugins({ + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["shared-runtime-options"], + }, + }, + }); + + expect(inheritedAgain).toBe(registry); + expect(plain).not.toBe(registry); + }); + it.each([ { name: "does not reuse cached bundled plugin registries across env changes", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6f5900f8334..b8096c5bbb1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -28,6 +28,10 @@ import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./ import { resolvePluginCacheInputs } from "./roots.js"; import { setActivePluginRegistry } from "./runtime.js"; import type { CreatePluginRuntimeOptions } from "./runtime/index.js"; +import { + getPluginRuntimeCapabilityKey, + getSharedPluginRuntimeOptions, +} from "./runtime/shared-runtime-options.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue } from "./schema-validator.js"; import { @@ -61,6 +65,7 @@ export type PluginLoadOptions = { logger?: PluginLogger; coreGatewayHandlers?: Record; runtimeOptions?: CreatePluginRuntimeOptions; + inheritSharedRuntimeOptions?: boolean; cache?: boolean; mode?: "full" | "validate"; onlyPluginIds?: string[]; @@ -242,7 +247,7 @@ function buildCacheKey(params: { onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; preferSetupRuntimeForChannelPlugins?: boolean; - runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; + runtimeOptions?: CreatePluginRuntimeOptions; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -273,7 +278,8 @@ function buildCacheKey(params: { ...params.plugins, installs, loadPaths, - })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}`; + runtimeCapabilities: getPluginRuntimeCapabilityKey(params.runtimeOptions), + })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -740,6 +746,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; const shouldActivate = options.activate !== false; + const effectiveRuntimeOptions = + options.runtimeOptions ?? + (options.inheritSharedRuntimeOptions ? getSharedPluginRuntimeOptions() : undefined); // NOTE: `activate` is intentionally excluded from the cache key. All non-activating // (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they // never read from or write to the cache. Including `activate` here would be misleading @@ -752,12 +761,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi onlyPluginIds, includeSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, - runtimeSubagentMode: - options.runtimeOptions?.allowGatewaySubagentBinding === true - ? "gateway-bindable" - : options.runtimeOptions?.subagent - ? "explicit" - : "default", + runtimeOptions: effectiveRuntimeOptions, }); const cacheEnabled = options.cache !== false; if (cacheEnabled) { @@ -828,7 +832,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // not eagerly load every channel/runtime dependency tree. let resolvedRuntime: PluginRuntime | null = null; const resolveRuntime = (): PluginRuntime => { - resolvedRuntime ??= resolveCreatePluginRuntime()(options.runtimeOptions); + resolvedRuntime ??= resolveCreatePluginRuntime()(effectiveRuntimeOptions); return resolvedRuntime; }; const lazyRuntimeReflectionKeySet = new Set(LAZY_RUNTIME_REFLECTION_KEYS); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 2fdadfeb94d..b3ae6ffa0b6 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -14,7 +14,6 @@ import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import { registerPluginInteractiveHandler } from "./interactive.js"; import { normalizeRegisteredProvider } from "./provider-validation.js"; -import { createEmptyPluginRegistry } from "./registry-empty.js"; import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; @@ -241,7 +240,28 @@ const constrainLegacyPromptInjectionHook = ( }; }; -export { createEmptyPluginRegistry } from "./registry-empty.js"; +export function createEmptyPluginRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + webSearchProviders: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + conversationBindingResolvedHandlers: [], + diagnostics: [], + }; +} export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); @@ -857,6 +877,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const subagent = Reflect.get(target, prop, receiver); return { run: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.run(params)), + enqueue: (params) => + withPluginRuntimePluginIdScope(pluginId, () => subagent.enqueue(params)), + abort: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.abort(params)), waitForRun: (params) => withPluginRuntimePluginIdScope(pluginId, () => subagent.waitForRun(params)), getSessionMessages: (params) => diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 5ffbd60aa2e..ac17e1834a3 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -113,6 +113,8 @@ describe("plugin runtime command execution", () => { const runtime = createPluginRuntime(); setGatewaySubagentRuntime({ run: vi.fn(), + enqueue: vi.fn(), + abort: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), getSession: vi.fn(), @@ -130,6 +132,8 @@ describe("plugin runtime command execution", () => { setGatewaySubagentRuntime({ run, + enqueue: vi.fn(), + abort: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), getSession: vi.fn(), @@ -141,4 +145,30 @@ describe("plugin runtime command execution", () => { }); expect(run).toHaveBeenCalledWith({ sessionKey: "s-2", message: "hello" }); }); + + it("uses the provided subagent runtime when available", async () => { + const subagent = { + run: vi.fn(async () => ({ runId: "run-1" })), + enqueue: vi.fn(async () => ({ runId: "run-1" })), + abort: vi.fn(async () => ({ aborted: true })), + waitForRun: vi.fn(async () => ({ status: "ok" as const })), + getSessionMessages: vi.fn(async () => ({ messages: [] })), + getSession: vi.fn(async () => ({ messages: [] })), + deleteSession: vi.fn(async () => undefined), + }; + + const runtime = createPluginRuntime({ subagent }); + await expect(runtime.subagent.run({ sessionKey: "s", message: "hi" })).resolves.toEqual({ + runId: "run-1", + }); + await expect(runtime.subagent.enqueue({ sessionKey: "s", message: "hi" })).resolves.toEqual({ + runId: "run-1", + }); + await expect(runtime.subagent.abort({ runId: "run-1" })).resolves.toEqual({ + aborted: true, + }); + expect(subagent.run).toHaveBeenCalledWith({ sessionKey: "s", message: "hi" }); + expect(subagent.enqueue).toHaveBeenCalledWith({ sessionKey: "s", message: "hi" }); + expect(subagent.abort).toHaveBeenCalledWith({ runId: "run-1" }); + }); }); diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 3f5b80d1caa..5681a1c3b58 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -50,6 +50,8 @@ function createUnavailableSubagentRuntime(): PluginRuntime["subagent"] { }; return { run: unavailable, + enqueue: unavailable, + abort: unavailable, waitForRun: unavailable, getSessionMessages: unavailable, getSession: unavailable, diff --git a/src/plugins/runtime/shared-runtime-options.ts b/src/plugins/runtime/shared-runtime-options.ts new file mode 100644 index 00000000000..c6991421d42 --- /dev/null +++ b/src/plugins/runtime/shared-runtime-options.ts @@ -0,0 +1,43 @@ +import type { CreatePluginRuntimeOptions } from "./index.js"; + +const SHARED_PLUGIN_RUNTIME_OPTIONS_KEY: unique symbol = Symbol.for( + "openclaw.sharedPluginRuntimeOptions", +); + +type SharedPluginRuntimeOptionsState = { + options?: CreatePluginRuntimeOptions; +}; + +function getSharedPluginRuntimeOptionsState(): SharedPluginRuntimeOptionsState { + const globalState = globalThis as typeof globalThis & { + [SHARED_PLUGIN_RUNTIME_OPTIONS_KEY]?: SharedPluginRuntimeOptionsState; + }; + const existing = globalState[SHARED_PLUGIN_RUNTIME_OPTIONS_KEY]; + if (existing) { + return existing; + } + const created: SharedPluginRuntimeOptionsState = {}; + globalState[SHARED_PLUGIN_RUNTIME_OPTIONS_KEY] = created; + return created; +} + +export function setSharedPluginRuntimeOptions(options: CreatePluginRuntimeOptions): void { + getSharedPluginRuntimeOptionsState().options = options; +} + +export function getSharedPluginRuntimeOptions(): CreatePluginRuntimeOptions | undefined { + return getSharedPluginRuntimeOptionsState().options; +} + +export function clearSharedPluginRuntimeOptions(): void { + getSharedPluginRuntimeOptionsState().options = undefined; +} + +export function getPluginRuntimeCapabilityKey( + options?: CreatePluginRuntimeOptions, +): Record { + return { + subagent: Boolean(options?.subagent), + gatewaySubagentBinding: options?.allowGatewaySubagentBinding === true, + }; +} diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index aa1118ecf92..ee0837b9503 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -1,3 +1,5 @@ +import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js"; +import type { AgentStreamParams } from "../../commands/agent/types.js"; import type { PluginRuntimeChannel } from "./types-channel.js"; import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js"; @@ -14,12 +16,19 @@ export type SubagentRunParams = { lane?: string; deliver?: boolean; idempotencyKey?: string; + clientTools?: ClientToolDefinition[]; + disableTools?: boolean; + streamParams?: AgentStreamParams; }; export type SubagentRunResult = { runId: string; }; +export type SubagentEnqueueParams = SubagentRunParams; + +export type SubagentEnqueueResult = SubagentRunResult; + export type SubagentWaitParams = { runId: string; timeoutMs?: number; @@ -28,6 +37,21 @@ export type SubagentWaitParams = { export type SubagentWaitResult = { status: "ok" | "error" | "timeout"; error?: string; + stopReason?: string; + pendingToolCalls?: Array<{ + id: string; + name: string; + arguments: string; + }>; +}; + +export type SubagentAbortParams = { + runId: string; + sessionKey?: string; +}; + +export type SubagentAbortResult = { + aborted: boolean; }; export type SubagentGetSessionMessagesParams = { @@ -53,6 +77,8 @@ export type SubagentDeleteSessionParams = { export type PluginRuntime = PluginRuntimeCore & { subagent: { run: (params: SubagentRunParams) => Promise; + enqueue: (params: SubagentEnqueueParams) => Promise; + abort: (params: SubagentAbortParams) => Promise; waitForRun: (params: SubagentWaitParams) => Promise; getSessionMessages: ( params: SubagentGetSessionMessagesParams, diff --git a/src/plugins/status.ts b/src/plugins/status.ts index a6b21541522..d7cbbea53b2 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -225,17 +225,6 @@ export function buildPluginInspectReport(params: { const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; const capabilityCount = capabilities.length; - const shape = deriveInspectShape({ - capabilityCount, - typedHookCount: typedHooks.length, - customHookCount: customHooks.length, - toolCount: tools.length, - commandCount: plugin.commands.length, - cliCount: plugin.cliCommands.length, - serviceCount: plugin.services.length, - gatewayMethodCount: plugin.gatewayMethods.length, - httpRouteCount: plugin.httpRoutes, - }); // Populate MCP server info for bundle-format plugins with a known rootDir. let mcpServers: PluginInspectReport["mcpServers"] = []; @@ -257,6 +246,18 @@ export function buildPluginInspectReport(params: { ]; } + const shape = deriveInspectShape({ + capabilityCount, + typedHookCount: typedHooks.length, + customHookCount: customHooks.length, + toolCount: tools.length, + commandCount: plugin.commands.length, + cliCount: plugin.cliCommands.length, + serviceCount: plugin.services.length, + gatewayMethodCount: plugin.gatewayMethods.length, + httpRouteCount: plugin.httpRoutes, + }); + // Populate LSP server info for bundle-format plugins with a known rootDir. let lspServers: PluginInspectReport["lspServers"] = []; if (plugin.format === "bundle" && plugin.bundleFormat && plugin.rootDir) { diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index c18f5008c31..0ca4e81496d 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -157,6 +157,20 @@ describe("resolvePluginTools optional tools", () => { expect(registry.diagnostics).toHaveLength(0); }); + it("loads plugin tools without re-initializing the global hook runner", () => { + setOptionalDemoRegistry(); + + resolvePluginTools({ + context: createContext() as never, + toolAllowlist: ["optional_tool"], + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( + expect.objectContaining({ + inheritSharedRuntimeOptions: true, + }), + ); + }); it("forwards an explicit env to plugin loading", () => { setOptionalDemoRegistry(); const env = { OPENCLAW_HOME: "/srv/openclaw-home" } as NodeJS.ProcessEnv; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 9a1142a8306..cf907bd48ec 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -68,6 +68,7 @@ export function resolvePluginTools(params: { } : undefined, env, + inheritSharedRuntimeOptions: true, logger: createPluginLoaderLogger(log), }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 343a338c4f8..e204c55c12a 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1670,6 +1670,7 @@ export type PluginHookMessageSentEvent = { content: string; success: boolean; error?: string; + metadata?: Record; }; // Tool context diff --git a/test/helpers/extensions/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts index c0b73a6e15d..8eed9753978 100644 --- a/test/helpers/extensions/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -327,6 +327,8 @@ export function createPluginRuntimeMock(overrides: DeepPartial = }, subagent: { run: vi.fn(), + enqueue: vi.fn(), + abort: vi.fn(), waitForRun: vi.fn(), getSessionMessages: vi.fn(), getSession: vi.fn(),