mirror of https://github.com/openclaw/openclaw.git
fix(plugins): late-binding subagent runtime for non-gateway load paths (#46648)
Merged via squash.
Prepared head SHA: 44742652c9
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
parent
abce640772
commit
eeb140b4f0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ export function createOpenClawTools(
|
|||
spawnWorkspaceDir?: string;
|
||||
/** Callback invoked when sessions_yield tool is called. */
|
||||
onYield?: (message: string) => Promise<void> | void;
|
||||
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
} & SpawnedToolContext,
|
||||
): AnyAgentTool[] {
|
||||
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
|
||||
|
|
@ -235,6 +237,7 @@ export function createOpenClawTools(
|
|||
},
|
||||
existingToolNames: new Set(tools.map((tool) => tool.name)),
|
||||
toolAllowlist: options?.pluginToolAllowlist,
|
||||
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
|
||||
});
|
||||
|
||||
return [...tools, ...pluginTools];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ async function notifyContextEngineSubagentEnded(params: {
|
|||
ensureRuntimePluginsLoaded({
|
||||
config: cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const engine = await resolveContextEngine(cfg);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -494,6 +494,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||
...embeddedContext,
|
||||
...senderContext,
|
||||
...runBaseParams,
|
||||
allowGatewaySubagentBinding: true,
|
||||
trigger: "memory",
|
||||
memoryFlushWritePath,
|
||||
prompt: resolveMemoryFlushPromptForRun({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export async function handleInlineActions(params: {
|
|||
agentDir,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ async function runFastModeCase(params: {
|
|||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
fastMode: params.expectedFastMode,
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -249,6 +249,9 @@ function loadSchemaWithPlugins(): ConfigSchemaResponse {
|
|||
config: cfg,
|
||||
cache: true,
|
||||
workspaceDir,
|
||||
runtimeOptions: {
|
||||
allowGatewaySubagentBinding: true,
|
||||
},
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ function buildPluginGroups(params: {
|
|||
existingToolNames: params.existingToolNames,
|
||||
toolAllowlist: ["group:plugins"],
|
||||
suppressNameConflicts: true,
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
const groups = new Map<string, ToolCatalogGroup>();
|
||||
for (const tool of pluginTools) {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,9 @@ async function importServerPluginsModule(): Promise<ServerPluginsModule> {
|
|||
return import("./server-plugins.js");
|
||||
}
|
||||
|
||||
function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntime["subagent"] {
|
||||
async function createSubagentRuntime(
|
||||
serverPlugins: ServerPluginsModule,
|
||||
): Promise<PluginRuntime["subagent"]> {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
|
|
@ -68,17 +70,20 @@ function createSubagentRuntime(serverPlugins: ServerPluginsModule): PluginRuntim
|
|||
baseMethods: [],
|
||||
});
|
||||
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
|
||||
| { runtimeOptions?: { subagent?: PluginRuntime["subagent"] } }
|
||||
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
|
||||
| undefined;
|
||||
if (!call?.runtimeOptions?.subagent) {
|
||||
throw new Error("Expected loadGatewayPlugins to provide subagent runtime");
|
||||
if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) {
|
||||
throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding");
|
||||
}
|
||||
return call.runtimeOptions.subagent;
|
||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||
return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
loadOpenClawPlugins.mockReset();
|
||||
handleGatewayRequest.mockReset();
|
||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||
runtimeModule.clearGatewaySubagentRuntime();
|
||||
handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
|
||||
switch (opts.req.method) {
|
||||
case "agent":
|
||||
|
|
@ -99,7 +104,9 @@ beforeEach(() => {
|
|||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||
runtimeModule.clearGatewaySubagentRuntime();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
|
|
@ -156,8 +163,14 @@ describe("loadGatewayPlugins", () => {
|
|||
baseMethods: [],
|
||||
});
|
||||
|
||||
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0];
|
||||
const subagent = call?.runtimeOptions?.subagent;
|
||||
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
|
||||
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
|
||||
| undefined;
|
||||
expect(call?.runtimeOptions?.allowGatewaySubagentBinding).toBe(true);
|
||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||
const subagent = runtimeModule.createPluginRuntime({
|
||||
allowGatewaySubagentBinding: true,
|
||||
}).subagent;
|
||||
expect(typeof subagent?.getSessionMessages).toBe("function");
|
||||
expect(typeof subagent?.getSession).toBe("function");
|
||||
});
|
||||
|
|
@ -223,7 +236,7 @@ describe("loadGatewayPlugins", () => {
|
|||
|
||||
test("shares fallback context across module reloads for existing runtimes", async () => {
|
||||
const first = await importServerPluginsModule();
|
||||
const runtime = createSubagentRuntime(first);
|
||||
const runtime = await createSubagentRuntime(first);
|
||||
|
||||
const staleContext = createTestContext("stale");
|
||||
first.setFallbackGatewayContext(staleContext);
|
||||
|
|
@ -241,7 +254,7 @@ describe("loadGatewayPlugins", () => {
|
|||
|
||||
test("uses updated fallback context after context replacement", async () => {
|
||||
const serverPlugins = await importServerPluginsModule();
|
||||
const runtime = createSubagentRuntime(serverPlugins);
|
||||
const runtime = await createSubagentRuntime(serverPlugins);
|
||||
const firstContext = createTestContext("before-restart");
|
||||
const secondContext = createTestContext("after-restart");
|
||||
|
||||
|
|
@ -256,7 +269,7 @@ describe("loadGatewayPlugins", () => {
|
|||
|
||||
test("reflects fallback context object mutation at dispatch time", async () => {
|
||||
const serverPlugins = await importServerPluginsModule();
|
||||
const runtime = createSubagentRuntime(serverPlugins);
|
||||
const runtime = await createSubagentRuntime(serverPlugins);
|
||||
const context = { marker: "before-mutation" } as GatewayRequestContext & {
|
||||
marker: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function resolvePluginTools(params: {
|
|||
existingToolNames?: Set<string>;
|
||||
toolAllowlist?: string[];
|
||||
suppressNameConflicts?: boolean;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): AnyAgentTool[] {
|
||||
// Fast path: when plugins are effectively disabled, avoid discovery/jiti entirely.
|
||||
|
|
@ -61,6 +62,11 @@ export function resolvePluginTools(params: {
|
|||
const registry = loadOpenClawPlugins({
|
||||
config: effectiveConfig,
|
||||
workspaceDir: params.context.workspaceDir,
|
||||
runtimeOptions: params.allowGatewaySubagentBinding
|
||||
? {
|
||||
allowGatewaySubagentBinding: true,
|
||||
}
|
||||
: undefined,
|
||||
env,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue