Skip to content

Commit eeb140b

Browse files
authored
fix(plugins): late-binding subagent runtime for non-gateway load paths (#46648)
Merged via squash. Prepared head SHA: 4474265 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent abce640 commit eeb140b

42 files changed

Lines changed: 555 additions & 28 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Docs: https://docs.openclaw.ai
103103
- 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.
104104
- 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.
105105
- 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.
106+
- 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.
106107
- 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.
107108

108109
## 2026.3.13

src/acp/translator.session-rate-limit.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import type {
55
SetSessionConfigOptionRequest,
66
SetSessionModeRequest,
77
} from "@agentclientprotocol/sdk";
8-
import { describe, expect, it, vi } from "vitest";
8+
import { beforeEach, describe, expect, it, vi } from "vitest";
99
import type { GatewayClient } from "../gateway/client.js";
1010
import type { EventFrame } from "../gateway/protocol/index.js";
11+
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
1112
import { createInMemorySessionStore } from "./session.js";
1213
import { AcpGatewayAgent } from "./translator.js";
1314
import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js";
@@ -119,6 +120,10 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text:
119120
sessionStore.clearAllSessionsForTest();
120121
}
121122

123+
beforeEach(() => {
124+
resetProviderRuntimeHookCacheForTest();
125+
});
126+
122127
describe("acp session creation rate limit", () => {
123128
it("rate limits excessive newSession bursts", async () => {
124129
const sessionStore = createInMemorySessionStore();
@@ -297,7 +302,14 @@ describe("acp session UX bridge behavior", () => {
297302
const result = await agent.loadSession(createLoadSessionRequest("agent:main:work"));
298303

299304
expect(result.modes?.currentModeId).toBe("high");
300-
expect(result.modes?.availableModes.map((mode) => mode.id)).toContain("xhigh");
305+
expect(result.modes?.availableModes.map((mode) => mode.id)).toEqual([
306+
"off",
307+
"minimal",
308+
"low",
309+
"medium",
310+
"high",
311+
"adaptive",
312+
]);
301313
expect(result.configOptions).toEqual(
302314
expect.arrayContaining([
303315
expect.objectContaining({

src/agents/openclaw-tools.plugin-context.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from "vitest";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const { resolvePluginToolsMock } = vi.hoisted(() => ({
44
resolvePluginToolsMock: vi.fn((params?: unknown) => {
@@ -9,11 +9,17 @@ const { resolvePluginToolsMock } = vi.hoisted(() => ({
99

1010
vi.mock("../plugins/tools.js", () => ({
1111
resolvePluginTools: resolvePluginToolsMock,
12+
getPluginToolMeta: vi.fn(() => undefined),
1213
}));
1314

1415
import { createOpenClawTools } from "./openclaw-tools.js";
16+
import { createOpenClawCodingTools } from "./pi-tools.js";
1517

1618
describe("createOpenClawTools plugin context", () => {
19+
beforeEach(() => {
20+
resolvePluginToolsMock.mockClear();
21+
});
22+
1723
it("forwards trusted requester sender identity to plugin tool context", () => {
1824
createOpenClawTools({
1925
config: {} as never,
@@ -47,4 +53,30 @@ describe("createOpenClawTools plugin context", () => {
4753
}),
4854
);
4955
});
56+
57+
it("forwards gateway subagent binding for plugin tools", () => {
58+
createOpenClawTools({
59+
config: {} as never,
60+
allowGatewaySubagentBinding: true,
61+
});
62+
63+
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
64+
expect.objectContaining({
65+
allowGatewaySubagentBinding: true,
66+
}),
67+
);
68+
});
69+
70+
it("forwards gateway subagent binding through coding tools", () => {
71+
createOpenClawCodingTools({
72+
config: {} as never,
73+
allowGatewaySubagentBinding: true,
74+
});
75+
76+
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
77+
expect.objectContaining({
78+
allowGatewaySubagentBinding: true,
79+
}),
80+
);
81+
});
5082
});

src/agents/openclaw-tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ export function createOpenClawTools(
8080
spawnWorkspaceDir?: string;
8181
/** Callback invoked when sessions_yield tool is called. */
8282
onYield?: (message: string) => Promise<void> | void;
83+
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
84+
allowGatewaySubagentBinding?: boolean;
8385
} & SpawnedToolContext,
8486
): AnyAgentTool[] {
8587
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
@@ -235,6 +237,7 @@ export function createOpenClawTools(
235237
},
236238
existingToolNames: new Set(tools.map((tool) => tool.name)),
237239
toolAllowlist: options?.pluginToolAllowlist,
240+
allowGatewaySubagentBinding: options?.allowGatewaySubagentBinding,
238241
});
239242

240243
return [...tools, ...pluginTools];

src/agents/pi-embedded-runner/compact.hooks.test.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
resolveSessionAgentIdMock,
1616
estimateTokensMock,
1717
sessionAbortCompactionMock,
18+
createOpenClawCodingToolsMock,
1819
} = vi.hoisted(() => {
1920
const contextEngineCompactMock = vi.fn(async () => ({
2021
ok: true as boolean,
@@ -36,12 +37,14 @@ const {
3637
info: { ownsCompaction: true },
3738
compact: contextEngineCompactMock,
3839
})),
39-
resolveModelMock: vi.fn(() => ({
40-
model: { provider: "openai", api: "responses", id: "fake", input: [] },
41-
error: null,
42-
authStorage: { setRuntimeApiKey: vi.fn() },
43-
modelRegistry: {},
44-
})),
40+
resolveModelMock: vi.fn(
41+
(_provider?: string, _modelId?: string, _agentDir?: string, _cfg?: unknown) => ({
42+
model: { provider: "openai", api: "responses", id: "fake", input: [] },
43+
error: null,
44+
authStorage: { setRuntimeApiKey: vi.fn() },
45+
modelRegistry: {},
46+
}),
47+
),
4548
sessionCompactImpl: vi.fn(async () => ({
4649
summary: "summary",
4750
firstKeptEntryId: "entry-1",
@@ -67,6 +70,7 @@ const {
6770
resolveSessionAgentIdMock: vi.fn(() => "main"),
6871
estimateTokensMock: vi.fn((_message?: unknown) => 10),
6972
sessionAbortCompactionMock: vi.fn(),
73+
createOpenClawCodingToolsMock: vi.fn(() => []),
7074
};
7175
});
7276

@@ -205,7 +209,7 @@ vi.mock("../channel-tools.js", () => ({
205209
}));
206210

207211
vi.mock("../pi-tools.js", () => ({
208-
createOpenClawCodingTools: vi.fn(() => []),
212+
createOpenClawCodingTools: createOpenClawCodingToolsMock,
209213
}));
210214

211215
vi.mock("./google.js", () => ({
@@ -307,6 +311,10 @@ vi.mock("./sandbox-info.js", () => ({
307311
vi.mock("./model.js", () => ({
308312
buildModelAliasLines: vi.fn(() => []),
309313
resolveModel: resolveModelMock,
314+
resolveModelAsync: vi.fn(
315+
async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) =>
316+
resolveModelMock(provider, modelId, agentDir, cfg),
317+
),
310318
}));
311319

312320
vi.mock("./session-manager-cache.js", () => ({
@@ -449,6 +457,26 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
449457
});
450458
});
451459

460+
it("forwards gateway subagent binding opt-in during compaction bootstrap", async () => {
461+
await compactEmbeddedPiSessionDirect({
462+
sessionId: "session-1",
463+
sessionFile: "/tmp/session.jsonl",
464+
workspaceDir: "/tmp/workspace",
465+
allowGatewaySubagentBinding: true,
466+
});
467+
468+
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
469+
config: undefined,
470+
workspaceDir: "/tmp/workspace",
471+
allowGatewaySubagentBinding: true,
472+
});
473+
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
474+
expect.objectContaining({
475+
allowGatewaySubagentBinding: true,
476+
}),
477+
);
478+
});
479+
452480
it("emits internal + plugin compaction hooks with counts", async () => {
453481
hookRunner.hasHooks.mockReturnValue(true);
454482
let sanitizedCount = 0;

src/agents/pi-embedded-runner/compact.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export type CompactEmbeddedPiSessionParams = {
147147
extraSystemPrompt?: string;
148148
ownerNumbers?: string[];
149149
abortSignal?: AbortSignal;
150+
/** Allow runtime plugins for this compaction to late-bind the gateway subagent. */
151+
allowGatewaySubagentBinding?: boolean;
150152
};
151153

152154
type CompactionMessageMetrics = {
@@ -384,6 +386,7 @@ export async function compactEmbeddedPiSessionDirect(
384386
ensureRuntimePluginsLoaded({
385387
config: params.config,
386388
workspaceDir: resolvedWorkspace,
389+
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
387390
});
388391
const prevCwd = process.cwd();
389392

@@ -570,6 +573,7 @@ export async function compactEmbeddedPiSessionDirect(
570573
groupSpace: params.groupSpace,
571574
spawnedBy: params.spawnedBy,
572575
senderIsOwner: params.senderIsOwner,
576+
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
573577
agentDir,
574578
workspaceDir: effectiveWorkspace,
575579
config: params.config,
@@ -1086,6 +1090,7 @@ export async function compactEmbeddedPiSession(
10861090
ensureRuntimePluginsLoaded({
10871091
config: params.config,
10881092
workspaceDir: params.workspaceDir,
1093+
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
10891094
});
10901095
ensureContextEnginesInitialized();
10911096
const contextEngine = await resolveContextEngine(params.config);

src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ vi.mock("./model.js", () => ({
156156
},
157157
modelRegistry: {},
158158
})),
159+
resolveModelAsync: vi.fn(async () => ({
160+
model: {
161+
id: "test-model",
162+
provider: "anthropic",
163+
contextWindow: 200000,
164+
api: "messages",
165+
},
166+
error: null,
167+
authStorage: {
168+
setRuntimeApiKey: vi.fn(),
169+
},
170+
modelRegistry: {},
171+
})),
159172
}));
160173

161174
vi.mock("../model-auth.js", () => ({

src/agents/pi-embedded-runner/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ export async function runEmbeddedPiAgent(
302302
ensureRuntimePluginsLoaded({
303303
config: params.config,
304304
workspaceDir: resolvedWorkspace,
305+
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
305306
});
306307
const prevCwd = process.cwd();
307308

@@ -952,6 +953,7 @@ export async function runEmbeddedPiAgent(
952953
workspaceDir: resolvedWorkspace,
953954
agentDir,
954955
config: params.config,
956+
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
955957
contextEngine,
956958
contextTokenBudget: ctxInfo.tokens,
957959
skillsSnapshot: params.skillsSnapshot,

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,7 @@ export async function runEmbeddedAttempt(
15081508
senderUsername: params.senderUsername,
15091509
senderE164: params.senderE164,
15101510
senderIsOwner: params.senderIsOwner,
1511+
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
15111512
sessionKey: sandboxSessionKey,
15121513
sessionId: params.sessionId,
15131514
runId: params.runId,

src/agents/pi-embedded-runner/run/params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export type RunEmbeddedPiAgentParams = {
6363
requireExplicitMessageTarget?: boolean;
6464
/** If true, omit the message tool from the tool list. */
6565
disableMessageTool?: boolean;
66+
/** Allow runtime plugins for this run to late-bind the gateway subagent. */
67+
allowGatewaySubagentBinding?: boolean;
6668
sessionFile: string;
6769
workspaceDir: string;
6870
agentDir?: string;

0 commit comments

Comments
 (0)