refactor(agents): bind subagent threads in core (#88416)

Move subagent thread binding ownership into core so session-mode spawns prepare channel bindings before launching the child agent. Deprecate the legacy subagent_spawning SDK hook in code, compatibility metadata, diagnostics, and plugin docs; plugin authors should observe subagent_spawned instead.

Verification:
- node scripts/run-vitest.mjs src/agents/sessions-spawn-hooks.test.ts src/agents/subagent-spawn.thread-binding.test.ts src/agents/subagent-spawn.workspace.test.ts src/agents/subagent-spawn.mode-session-diagnostics.test.ts
- node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core.tsbuildinfo
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- CI run 26693808952 green, including checks-node-agentic-agents-core and checks-node-agentic-plugin-sdk
This commit is contained in:
Peter Steinberger
2026-05-30 21:19:09 +01:00
committed by GitHub
parent 4ac90a5b48
commit 3fc0df953c
25 changed files with 796 additions and 248 deletions
+7 -1
View File
@@ -141,7 +141,9 @@ observation-only.
**Subagents**
- `subagent_spawning` / `subagent_delivery_target` / `subagent_spawned` / `subagent_ended` - coordinate subagent routing and completion delivery
- `subagent_spawned` / `subagent_ended` - observe subagent launch and completion.
- `subagent_delivery_target` - compatibility hook for completion delivery when no core session binding can project a route.
- `subagent_spawning` - deprecated compatibility hook. Core now prepares `thread: true` subagent bindings through channel session-binding adapters before `subagent_spawned` fires.
- `subagent_spawned` includes `resolvedModel` and `resolvedProvider` when OpenClaw has resolved the child session's native model before launch.
**Lifecycle**
@@ -464,6 +466,10 @@ before the next major release:
- **`before_agent_start`** remains for compatibility. New plugins should use
`before_model_resolve` and `before_prompt_build` instead of the combined
phase.
- **`subagent_spawning`** remains for compatibility with older plugins, but
new plugins should not return thread routing from it. Core prepares
`thread: true` subagent bindings through channel session-binding adapters
before `subagent_spawned` fires.
- **`deactivate`** remains as a deprecated cleanup compatibility alias until
after 2026-08-16. New plugins should use `gateway_stop`.
- **`onResolution` in `before_tool_call`** now uses the typed
+29
View File
@@ -792,6 +792,35 @@ canonical replacement.
</Accordion>
<Accordion title="subagent_spawning hook → core thread binding">
**Old**: `api.on("subagent_spawning", handler)` returning
`threadBindingReady` or `deliveryOrigin`.
**New**: let core prepare `thread: true` subagent bindings through the
channel session-binding adapter. Use `api.on("subagent_spawned", handler)`
only for post-launch observation.
```typescript
// Before
api.on("subagent_spawning", async () => ({
status: "ok",
threadBindingReady: true,
deliveryOrigin: { channel: "discord", to: "channel:123", threadId: "456" },
}));
// After
api.on("subagent_spawned", async (event) => {
await observeSubagentLaunch(event);
});
```
`subagent_spawning`, `PluginHookSubagentSpawningEvent`,
`PluginHookSubagentSpawningResult`, and
`SubagentLifecycleHookRunner.runSubagentSpawning(...)` remain only as
deprecated compatibility surfaces while external plugins migrate.
</Accordion>
<Accordion title="Provider discovery types → provider catalog types">
Four discovery type aliases are now thin wrappers over the
catalog-era types:
+6 -8
View File
@@ -291,14 +291,12 @@ same sub-agent session.
### Thread supporting channels
**Discord** is currently the only supported channel. It supports
persistent thread-bound subagent sessions (`sessions_spawn` with
`thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`,
`/session idle`, `/session max-age`), and adapter keys
`channels.discord.threadBindings.enabled`,
`channels.discord.threadBindings.idleHours`,
`channels.discord.threadBindings.maxAgeHours`, and
`channels.discord.threadBindings.spawnSessions`.
Any channel with a session-binding adapter can support persistent
thread-bound subagent sessions (`sessions_spawn` with `thread: true`).
Bundled adapters currently include Discord threads, Matrix threads,
Telegram forum topics, and current-conversation bindings for Feishu.
Use the per-channel `threadBindings` config keys for enablement,
timeouts, and `spawnSessions`.
### Quick flow
@@ -4,6 +4,7 @@ import {
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { handleDiscordSubagentSpawning } from "./subagent-hooks.js";
type ThreadBindingRecord = {
accountId: string;
@@ -85,7 +86,10 @@ function registerHandlersForTest(
) {
return registerHookHandlersForTest<OpenClawPluginApi>({
config,
register: registerDiscordSubagentHooks,
register: (api) => {
registerDiscordSubagentHooks(api);
api.on("subagent_spawning", (event) => handleDiscordSubagentSpawning(api, event));
},
});
}
-4
View File
@@ -12,10 +12,6 @@ function loadDiscordSubagentHooksModule() {
// Subagent hooks live behind a dedicated barrel so the bundled entry can
// register one stable hook wiring path while keeping the handler module lazy.
export function registerDiscordSubagentHooks(api: OpenClawPluginApi): void {
api.on("subagent_spawning", async (event) => {
const { handleDiscordSubagentSpawning } = await loadDiscordSubagentHooksModule();
return await handleDiscordSubagentSpawning(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule();
handleDiscordSubagentEnded(event);
+5 -1
View File
@@ -5,6 +5,7 @@ import {
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig, OpenClawPluginApi } from "../runtime-api.js";
import { registerFeishuSubagentHooks } from "../subagent-hooks-api.js";
import { handleFeishuSubagentSpawning } from "./subagent-hooks.js";
import {
createFeishuThreadBindingManager,
testing as threadBindingTesting,
@@ -18,7 +19,10 @@ const baseConfig: ClawdbotConfig = {
function registerHandlersForTest(config: Record<string, unknown> = baseConfig) {
return registerHookHandlersForTest<OpenClawPluginApi>({
config,
register: registerFeishuSubagentHooks,
register: (api) => {
registerFeishuSubagentHooks(api);
api.on("subagent_spawning", (event, ctx) => handleFeishuSubagentSpawning(event, ctx));
},
});
}
-4
View File
@@ -10,10 +10,6 @@ function loadFeishuSubagentHooksModule() {
}
export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
api.on("subagent_spawning", async (event, ctx) => {
const { handleFeishuSubagentSpawning } = await loadFeishuSubagentHooksModule();
return await handleFeishuSubagentSpawning(event, ctx);
});
api.on("subagent_delivery_target", async (event) => {
const { handleFeishuSubagentDeliveryTarget } = await loadFeishuSubagentHooksModule();
return handleFeishuSubagentDeliveryTarget(event);
-3
View File
@@ -135,17 +135,14 @@ describe("matrix plugin", () => {
expect(runtimeMocks.ensureMatrixCryptoRuntime).not.toHaveBeenCalled();
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
"subagent_spawning",
"subagent_ended",
"subagent_delivery_target",
]);
const handlers = Object.fromEntries(on.mock.calls);
await expect(handlers.subagent_spawning({ id: "spawn" })).resolves.toBe("spawned");
await expect(handlers.subagent_ended({ id: "ended" })).resolves.toBeUndefined();
await expect(handlers.subagent_delivery_target({ id: "target" })).resolves.toBe(
"delivery-target",
);
expect(runtimeMocks.handleMatrixSubagentSpawning).toHaveBeenCalledWith(api, { id: "spawn" });
expect(runtimeMocks.handleMatrixSubagentEnded).toHaveBeenCalledWith({ id: "ended" });
expect(runtimeMocks.handleMatrixSubagentDeliveryTarget).toHaveBeenCalledWith({ id: "target" });
});
@@ -55,7 +55,10 @@ const fakeApi = { config: {} } as never;
function registerHandlersForTest(config: Record<string, unknown> = {}) {
return registerHookHandlersForTest<MatrixEntryPluginApi>({
config,
register: registerMatrixSubagentHooks,
register: (api) => {
registerMatrixSubagentHooks(api);
api.on("subagent_spawning", (event) => handleMatrixSubagentSpawning(api, event));
},
});
}
-4
View File
@@ -10,10 +10,6 @@ function loadMatrixSubagentHooksModule() {
}
export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
api.on("subagent_spawning", async (event) => {
const { handleMatrixSubagentSpawning } = await loadMatrixSubagentHooksModule();
return await handleMatrixSubagentSpawning(api, event);
});
api.on("subagent_ended", async (event) => {
const { handleMatrixSubagentEnded } = await loadMatrixSubagentHooksModule();
await handleMatrixSubagentEnded(event);
@@ -29,18 +29,7 @@ const fastModeEnv = vi.hoisted(() => {
});
const hookRunnerMocks = vi.hoisted(() => ({
runSubagentSpawning: vi.fn(async (event: unknown) => {
const input = event as {
threadRequested?: boolean;
};
if (!input.threadRequested) {
return undefined;
}
return {
status: "ok" as const,
threadBindingReady: true,
};
}),
runSubagentSpawning: vi.fn(async () => undefined),
runSubagentSpawned: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
@@ -198,9 +187,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
hookRunnerMocks.runSubagentEnded.mockClear();
setSessionsSpawnHookRunnerOverride({
hasHooks: (hookName: string) =>
hookName === "subagent_spawning" ||
hookName === "subagent_spawned" ||
hookName === "subagent_ended",
hookName === "subagent_spawned" || hookName === "subagent_ended",
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
+100 -62
View File
@@ -5,6 +5,18 @@ import {
} from "./subagent-spawn.test-helpers.js";
type GatewayRequest = { method?: string; params?: Record<string, unknown> };
type TestBindingRequest = {
targetSessionKey: string;
targetKind?: string;
conversation: {
channel: string;
accountId?: string;
conversationId: string;
parentConversationId?: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
};
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
@@ -14,31 +26,33 @@ const hoisted = vi.hoisted(() => ({
const hookRunnerMocks = vi.hoisted(() => ({
hasSubagentEndedHook: true,
runSubagentSpawning: vi.fn(async (event: unknown) => {
const input = event as {
threadRequested?: boolean;
requester?: { channel?: string };
};
if (!input.threadRequested) {
return undefined;
}
const channel = input.requester?.channel?.trim().toLowerCase();
if (channel !== "discord") {
const channelLabel = input.requester?.channel?.trim() || "unknown";
return {
status: "error" as const,
error: `thread=true is not supported for channel "${channelLabel}". Only Discord thread-bound subagent sessions are supported right now.`,
};
}
return {
status: "ok" as const,
threadBindingReady: true,
};
}),
runSubagentSpawned: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
const bindingMocks = vi.hoisted(() => ({
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
placements: ["child"] as Array<"current" | "child">,
})),
bind: vi.fn(async (request: TestBindingRequest) => {
const conversation = request.conversation;
return {
targetSessionKey: request.targetSessionKey,
targetKind: request.targetKind,
status: "active",
conversation: {
channel: conversation.channel,
accountId: conversation.accountId ?? "default",
conversationId: "456",
parentConversationId: conversation.conversationId,
},
};
}),
listBySession: vi.fn(() => []),
}));
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
@@ -186,13 +200,12 @@ beforeAll(async () => {
updateSessionStoreMock: hoisted.updateSessionStoreMock,
hookRunner: {
hasHooks: (hookName: string) =>
hookName === "subagent_spawning" ||
hookName === "subagent_spawned" ||
(hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook),
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
},
getSessionBindingService: () => bindingMocks,
resetModules: false,
sessionStorePath: "/tmp/subagent-spawn-hooks-session-store.json",
}));
@@ -204,9 +217,30 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
hoisted.callGatewayMock.mockReset();
hoisted.updateSessionStoreMock.mockReset();
hookRunnerMocks.hasSubagentEndedHook = true;
hookRunnerMocks.runSubagentSpawning.mockClear();
hookRunnerMocks.runSubagentSpawned.mockClear();
hookRunnerMocks.runSubagentEnded.mockClear();
bindingMocks.getCapabilities.mockClear();
bindingMocks.getCapabilities.mockReturnValue({
adapterAvailable: true,
bindSupported: true,
placements: ["child"],
});
bindingMocks.bind.mockClear();
bindingMocks.bind.mockImplementation(async (request: TestBindingRequest) => {
const conversation = request.conversation;
return {
targetSessionKey: request.targetSessionKey,
targetKind: request.targetKind,
status: "active",
conversation: {
channel: conversation.channel,
accountId: conversation.accountId ?? "default",
conversationId: "456",
parentConversationId: conversation.conversationId,
},
};
});
bindingMocks.listBySession.mockClear();
setConfig({
session: {
mainKey: "main",
@@ -245,7 +279,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
resetSubagentRegistryForTests();
});
it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => {
it("binds the subagent thread in core and emits subagent_spawned with requester metadata", async () => {
const result = await spawn({
label: "research",
model: "openai-codex/gpt-5.4",
@@ -267,38 +301,34 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
},
"spawn result",
);
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
const [spawningEvent, spawningContext] = (hookRunnerMocks.runSubagentSpawning.mock.calls.at(
0,
) ?? []) as unknown as [Record<string, unknown>, Record<string, unknown>];
const spawningChildSessionKey = expectSubagentSessionKey(
spawningEvent?.childSessionKey,
"spawning event child session key",
expect(bindingMocks.getCapabilities).toHaveBeenCalledWith({
channel: "discord",
accountId: "work",
});
expect(bindingMocks.bind).toHaveBeenCalledTimes(1);
const bindingRequest = requireRecord(bindingMocks.bind.mock.calls[0]?.[0], "binding request");
const bindingChildSessionKey = expectSubagentSessionKey(
bindingRequest.targetSessionKey,
"binding target session key",
);
expectFields(
spawningEvent,
bindingRequest,
{
childSessionKey: spawningChildSessionKey,
agentId: "main",
label: "research",
mode: "session",
requester: {
channel: "discord",
accountId: "work",
to: "channel:123",
threadId: 456,
},
threadRequested: true,
targetSessionKey: bindingChildSessionKey,
targetKind: "subagent",
placement: "child",
},
"spawning event",
"binding request",
);
expectFields(
spawningContext,
bindingRequest.conversation,
{
childSessionKey: spawningChildSessionKey,
requesterSessionKey: "main",
channel: "discord",
accountId: "work",
conversationId: "456",
parentConversationId: "123",
},
"spawning context",
"binding conversation",
);
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
@@ -345,7 +375,6 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result");
expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
const event = getSpawnedEventCall();
expectFields(
@@ -376,7 +405,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
expectFields(result, { status: "accepted", runId: "run-1", mode: "run" }, "spawn result");
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
expect(bindingMocks.bind).toHaveBeenCalledTimes(1);
const event = getSpawnedEventCall();
expectFields(
event,
@@ -389,10 +418,9 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
it("returns error when thread binding cannot be created", async () => {
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
status: "error",
error: "Unable to create or bind a Discord thread for this subagent session.",
});
bindingMocks.bind.mockRejectedValueOnce(
new Error("Unable to create or bind a Discord thread for this subagent session."),
);
const result = await spawn({
toolCallId: "call4",
runTimeoutSeconds: 1,
@@ -406,10 +434,17 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
expectThreadBindFailureCleanup(result, /thread/i);
});
it("returns error when thread binding is not marked ready", async () => {
hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
status: "ok",
threadBindingReady: false,
it("returns error when thread binding does not produce a conversation", async () => {
bindingMocks.bind.mockResolvedValueOnce({
targetSessionKey: "agent:main:subagent:test",
targetKind: "subagent",
status: "active",
conversation: {
channel: "discord",
accountId: "work",
conversationId: "",
parentConversationId: "123",
},
});
const result = await spawn({
toolCallId: "call4b",
@@ -431,12 +466,16 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
expectErrorResultMessage(result, /requires thread=true/i);
expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("rejects thread=true on channels without thread support", async () => {
bindingMocks.getCapabilities.mockReturnValueOnce({
adapterAvailable: false,
bindSupported: false,
placements: [],
});
const result = await spawn({
thread: true,
mode: "session",
@@ -445,8 +484,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
expectErrorResultMessage(result, /only discord/i);
expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
expectErrorResultMessage(result, /only available on channels that expose thread bindings/i);
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expectSessionsDeleteWithoutAgentStart();
});
@@ -1,13 +1,10 @@
import os from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import {
createSubagentSpawnTestConfig,
loadSubagentSpawnModuleForTest,
} from "./subagent-spawn.test-helpers.js";
type SubagentSpawningEvent = Parameters<SubagentLifecycleHookRunner["runSubagentSpawning"]>[0];
describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
const callGatewayMock = vi.fn();
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
@@ -66,7 +63,7 @@ describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
});
});
describe('spawnSubagentDirect mode="session" with registered thread hooks (#67400)', () => {
describe('spawnSubagentDirect mode="session" with thread binding-capable channels (#67400)', () => {
const callGatewayMock = vi.fn();
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
@@ -77,19 +74,6 @@ describe('spawnSubagentDirect mode="session" with registered thread hooks (#6740
callGatewayMock,
getRuntimeConfig: () => createSubagentSpawnTestConfig(os.tmpdir()),
workspaceDir: os.tmpdir(),
hookRunner: {
hasHooks: () => true,
runSubagentSpawning: async (event: SubagentSpawningEvent) => {
const requesterChannel = event.requester?.channel;
if (requesterChannel !== "discord") {
return undefined;
}
return {
status: "ok" as const,
threadBindingReady: true,
};
},
},
}));
resetSubagentRegistryForTests();
});
+1
View File
@@ -13,6 +13,7 @@ export { ensureContextEnginesInitialized } from "../context-engine/init.js";
export { resolveContextEngine } from "../context-engine/registry.js";
export { callGateway } from "../gateway/call.js";
export { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
export { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
export {
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
+46 -3
View File
@@ -9,8 +9,13 @@ type MockImplementationTarget = {
};
type SessionStore = Record<string, Record<string, unknown>>;
type SessionStoreMutator = (store: SessionStore) => unknown;
type HookRunner = Pick<SubagentLifecycleHookRunner, "hasHooks" | "runSubagentSpawning"> &
Partial<Pick<SubagentLifecycleHookRunner, "runSubagentSpawned" | "runSubagentEnded">>;
type HookRunner = Pick<SubagentLifecycleHookRunner, "hasHooks"> &
Partial<
Pick<
SubagentLifecycleHookRunner,
"runSubagentSpawning" | "runSubagentSpawned" | "runSubagentEnded"
>
>;
type SubagentSpawnModuleForTest = Awaited<typeof import("./subagent-spawn.js")> & {
resetSubagentRegistryForTests: MockFn;
};
@@ -136,6 +141,33 @@ export async function loadSubagentSpawnModuleForTest(params: {
sessionKey?: string;
}) => { sandboxed: boolean };
getSessionBindingService?: () => {
getCapabilities?: (params: { channel?: string; accountId?: string }) => {
adapterAvailable: boolean;
bindSupported: boolean;
placements: Array<"current" | "child">;
};
bind?: (params: {
targetSessionKey: string;
targetKind?: string;
conversation: {
channel: string;
accountId?: string;
conversationId: string;
parentConversationId?: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
}) => Promise<{
targetSessionKey: string;
targetKind?: string;
status?: string;
conversation: {
channel: string;
accountId?: string;
conversationId: string;
parentConversationId?: string;
};
}>;
listBySession: (targetSessionKey: string) => Array<{
status?: string;
conversation: {
@@ -224,7 +256,18 @@ export async function loadSubagentSpawnModuleForTest(params: {
method === "sessions.patch" || method === "sessions.delete",
pruneLegacyStoreKeys: (...args: unknown[]) => params.pruneLegacyStoreKeysMock?.(...args),
getSessionBindingService:
params.getSessionBindingService ?? (() => ({ listBySession: () => [] })),
params.getSessionBindingService ??
(() => ({
getCapabilities: () => ({
adapterAvailable: false,
bindSupported: false,
placements: [],
}),
bind: async () => {
throw new Error("session binding adapter unavailable");
},
listBySession: () => [],
})),
resolveConversationDeliveryTarget:
params.resolveConversationDeliveryTarget ??
((targetParams: { channel?: string; conversationId?: string | number }) => ({
+115 -62
View File
@@ -14,7 +14,6 @@ const hoisted = vi.hoisted(() => ({
emitSessionLifecycleEventMock: vi.fn(),
hookRunner: {
hasHooks: vi.fn(),
runSubagentSpawning: vi.fn(),
},
}));
@@ -44,6 +43,10 @@ function firstRegisteredSubagentRun(): {
describe("spawnSubagentDirect thread binding delivery", () => {
type SpawnModule = Awaited<ReturnType<typeof loadSubagentSpawnModuleForTest>>;
type SetActivePluginRegistry = typeof import("../plugins/runtime.js").setActivePluginRegistry;
type CreateChannelTestPluginBase =
typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase;
type CreateTestRegistry = typeof import("../test-utils/channel-plugins.js").createTestRegistry;
type SessionBindingService = NonNullable<
Parameters<typeof loadSubagentSpawnModuleForTest>[0]["getSessionBindingService"]
>;
@@ -52,6 +55,9 @@ describe("spawnSubagentDirect thread binding delivery", () => {
>;
let spawnSubagentDirect: SpawnModule["spawnSubagentDirect"];
let setActivePluginRegistryForTest: SetActivePluginRegistry;
let createChannelTestPluginBaseForTest: CreateChannelTestPluginBase;
let createTestRegistryForTest: CreateTestRegistry;
let currentConfig: Record<string, unknown>;
let currentSessionBindingService: ReturnType<SessionBindingService>;
let currentDeliveryTargetResolver: DeliveryTargetResolver;
@@ -69,9 +75,47 @@ describe("spawnSubagentDirect thread binding delivery", () => {
getSessionBindingService: () => currentSessionBindingService,
resolveConversationDeliveryTarget: (params) => currentDeliveryTargetResolver(params),
}));
({ setActivePluginRegistry: setActivePluginRegistryForTest } =
await import("../plugins/runtime.js"));
({
createChannelTestPluginBase: createChannelTestPluginBaseForTest,
createTestRegistry: createTestRegistryForTest,
} = await import("../test-utils/channel-plugins.js"));
});
function installChannelRouteProjectionPluginsForTest() {
const matrixBase = createChannelTestPluginBaseForTest({ id: "matrix", label: "Matrix" });
setActivePluginRegistryForTest(
createTestRegistryForTest([
{
pluginId: "matrix",
source: "test",
plugin: {
...matrixBase,
messaging: {
resolveDeliveryTarget: ({
conversationId,
parentConversationId,
}: {
conversationId: string;
parentConversationId?: string;
}) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
if (parent && parent !== child) {
return { to: `room:${parent}`, threadId: child };
}
return { to: `room:${child}` };
},
},
},
},
]),
);
}
beforeEach(() => {
installChannelRouteProjectionPluginsForTest();
currentConfig = createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
@@ -85,7 +129,24 @@ describe("spawnSubagentDirect thread binding delivery", () => {
},
},
});
currentSessionBindingService = { listBySession: () => [] };
currentSessionBindingService = {
getCapabilities: () => ({
adapterAvailable: true,
bindSupported: true,
placements: ["child"],
}),
bind: async (request) => ({
targetSessionKey: request.targetSessionKey,
targetKind: request.targetKind,
status: "active",
conversation: {
channel: request.conversation.channel,
accountId: request.conversation.accountId,
conversationId: request.conversation.conversationId,
},
}),
listBySession: () => [],
};
currentDeliveryTargetResolver = (params) => ({
to: params.conversationId ? `channel:${String(params.conversationId)}` : undefined,
});
@@ -94,40 +155,35 @@ describe("spawnSubagentDirect thread binding delivery", () => {
hoisted.registerSubagentRunMock.mockReset();
hoisted.emitSessionLifecycleEventMock.mockReset();
hoisted.hookRunner.hasHooks.mockReset();
hoisted.hookRunner.runSubagentSpawning.mockReset();
installAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
});
it("passes the target agent's bound account to thread binding hooks", async () => {
it("passes the target agent's bound account to core thread binding", async () => {
const boundRoom = "!room:example.org";
let hookRequester:
| { channel?: string; accountId?: string; to?: string; threadId?: string | number }
| undefined;
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockImplementation(async (event: unknown) => {
hookRequester = (
event as {
requester?: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
}
).requester;
return {
status: "ok",
threadBindingReady: true,
deliveryOrigin: {
channel: "matrix",
to: `room:${boundRoom}`,
threadId: "$thread-root",
},
};
});
const bindCalls: Array<Record<string, unknown>> = [];
currentSessionBindingService = {
getCapabilities: () => ({
adapterAvailable: true,
bindSupported: true,
placements: ["child"],
}),
bind: async (request) => {
bindCalls.push(request as unknown as Record<string, unknown>);
return {
targetSessionKey: request.targetSessionKey,
targetKind: request.targetKind,
status: "active",
conversation: {
channel: request.conversation.channel,
accountId: request.conversation.accountId,
conversationId: "$thread-root",
parentConversationId: request.conversation.conversationId,
},
};
},
listBySession: () => [],
};
currentConfig = createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
@@ -174,9 +230,13 @@ describe("spawnSubagentDirect thread binding delivery", () => {
);
expect(result.status).toBe("accepted");
expect(hookRequester?.channel).toBe("matrix");
expect(hookRequester?.accountId).toBe("bot-alpha");
expect(hookRequester?.to).toBe(`room:${boundRoom}`);
expect(bindCalls).toHaveLength(1);
const bindingConversation = bindCalls[0]?.conversation as
| { channel?: string; accountId?: string; conversationId?: string }
| undefined;
expect(bindingConversation?.channel).toBe("matrix");
expect(bindingConversation?.accountId).toBe("bot-alpha");
expect(bindingConversation?.conversationId).toBe(boundRoom);
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record<string, unknown> } | undefined;
@@ -194,20 +254,6 @@ describe("spawnSubagentDirect thread binding delivery", () => {
});
it("uses controller ownership for thread binding while completion routes to owner", async () => {
let hookRequesterSessionKey: string | undefined;
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockImplementation(
async (eventValue: unknown, ctx?: { requesterSessionKey?: string }) => {
hookRequesterSessionKey = ctx?.requesterSessionKey;
return {
status: "ok",
threadBindingReady: true,
};
},
);
const result = await spawnSubagentDirect(
{
task: "reply with a marker",
@@ -225,22 +271,29 @@ describe("spawnSubagentDirect thread binding delivery", () => {
);
expect(result.status).toBe("accepted");
expect(hookRequesterSessionKey).toBe("agent:main:telegram:default:direct:456");
const registeredRun = firstRegisteredSubagentRun();
expect(registeredRun.controllerSessionKey).toBe("agent:main:telegram:default:direct:456");
expect(registeredRun.requesterSessionKey).toBe("agent:main:main");
expect(registeredRun.requesterDisplayKey).toBe("agent:main:main");
});
it("keeps completion announcements when only a generic binding is available", async () => {
hoisted.hookRunner.hasHooks.mockImplementation(
(hookName?: string) => hookName === "subagent_spawning",
);
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
status: "ok",
threadBindingReady: true,
});
it("uses core binding delivery when only a generic route projection is available", async () => {
currentSessionBindingService = {
getCapabilities: () => ({
adapterAvailable: true,
bindSupported: true,
placements: ["child"],
}),
bind: async (request) => ({
targetSessionKey: request.targetSessionKey,
targetKind: request.targetKind,
status: "active",
conversation: {
channel: "collabchat",
accountId: "work",
conversationId: "collab_dm_1",
},
}),
listBySession: () => [
{
status: "active",
@@ -275,12 +328,12 @@ describe("spawnSubagentDirect thread binding delivery", () => {
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record<string, unknown> } | undefined;
expect(agentCall?.params?.channel).toBe("matrix");
expect(agentCall?.params?.accountId).toBe("sut");
expect(agentCall?.params?.to).toBe("room:!parent:example");
expect(agentCall?.params?.deliver).toBe(false);
expect(agentCall?.params?.channel).toBe("collabchat");
expect(agentCall?.params?.accountId).toBe("work");
expect(agentCall?.params?.to).toBe("channel:collab_dm_1");
expect(agentCall?.params?.deliver).toBe(true);
const registeredRun = firstRegisteredSubagentRun();
expect(registeredRun?.expectsCompletionMessage).toBe(true);
expect(registeredRun?.expectsCompletionMessage).toBe(false);
expect(registeredRun?.requesterOrigin?.channel).toBe("matrix");
expect(registeredRun?.requesterOrigin?.accountId).toBe("sut");
expect(registeredRun?.requesterOrigin?.to).toBe("room:!parent:example");
+289 -36
View File
@@ -2,7 +2,22 @@ import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js";
import { resolveThreadBindingSpawnPolicy } from "../channels/thread-bindings-policy.js";
import {
resolveChannelDefaultBindingPlacement,
resolveInboundConversationResolution,
} from "../channels/conversation-resolution.js";
import { routeFromBindingRecord, routeToDeliveryFields } from "../channels/route-projection.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../channels/thread-bindings-messages.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
@@ -11,7 +26,10 @@ import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-regi
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { finiteSecondsToTimerSafeMilliseconds } from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { listAgentIds, resolveAgentDir } from "./agent-scope-config.js";
@@ -60,6 +78,7 @@ import {
emitSessionLifecycleEvent,
forkSessionFromParent,
getGlobalHookRunner,
getSessionBindingService,
getRuntimeConfig,
mergeSessionEntry,
mergeDeliveryContext,
@@ -642,8 +661,223 @@ function buildThreadBindingUnavailableError(mode: SpawnSubagentMode): string {
);
}
async function ensureThreadBindingForSubagentSpawn(params: {
hookRunner: SubagentLifecycleHookRunner | null;
type PreparedSubagentThreadBinding = {
channel: string;
accountId: string;
placement: "current" | "child";
conversationId: string;
parentConversationId?: string;
};
function resolvePlacementWithoutChannelPlugin(params: {
capabilities: { placements: Array<"current" | "child"> };
}): "current" | "child" {
return params.capabilities.placements.includes("child") ? "child" : "current";
}
function resolveSubagentSpawnChannelAccountId(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
}): string | undefined {
const channel = normalizeOptionalLowercaseString(params.channel);
const explicitAccountId = normalizeOptionalString(params.accountId);
if (explicitAccountId) {
return explicitAccountId;
}
if (!channel) {
return undefined;
}
const channels = params.cfg.channels as Record<string, { defaultAccount?: unknown } | undefined>;
return normalizeOptionalString(channels?.[channel]?.defaultAccount) ?? "default";
}
function resolveConversationRefForThreadBinding(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
}): { conversationId: string; parentConversationId?: string } | null {
const resolution = resolveInboundConversationResolution({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
to: params.to,
threadId: params.threadId,
isGroup: true,
});
return resolution?.canonical ?? null;
}
function resolveRequesterBoundConversationRef(params: {
requesterSessionKey?: string;
channel: string;
accountId: string;
fallback?: { conversationId: string; parentConversationId?: string } | null;
}): { conversationId: string; parentConversationId?: string } | null | undefined {
const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
if (!requesterSessionKey) {
return undefined;
}
const activeBindings = getSessionBindingService()
.listBySession(requesterSessionKey)
.filter(
(record) =>
record.status !== "ended" &&
record.conversation.channel === params.channel &&
(record.conversation.accountId ?? params.accountId) === params.accountId,
);
if (activeBindings.length === 0) {
return undefined;
}
if (activeBindings.length === 1) {
const conversation = activeBindings[0].conversation;
return {
conversationId: conversation.conversationId,
...(conversation.parentConversationId
? { parentConversationId: conversation.parentConversationId }
: {}),
};
}
if (params.fallback?.conversationId) {
const matched = activeBindings.filter(
(record) =>
record.conversation.conversationId === params.fallback?.conversationId &&
normalizeOptionalString(record.conversation.parentConversationId) ===
normalizeOptionalString(params.fallback?.parentConversationId),
);
if (matched.length === 1) {
const conversation = matched[0].conversation;
return {
conversationId: conversation.conversationId,
...(conversation.parentConversationId
? { parentConversationId: conversation.parentConversationId }
: {}),
};
}
}
return null;
}
function prepareSubagentThreadBinding(params: {
cfg: OpenClawConfig;
mode: SpawnSubagentMode;
requesterSessionKey?: string;
requester: {
channel?: string;
accountId?: string;
to?: string;
threadId?: string | number;
};
}): { ok: true; binding: PreparedSubagentThreadBinding } | { ok: false; error: string } {
const channel = normalizeOptionalLowercaseString(params.requester.channel);
if (!channel) {
return {
ok: false,
error: buildThreadBindingUnavailableError(params.mode),
};
}
const accountId = resolveSubagentSpawnChannelAccountId({
cfg: params.cfg,
channel,
accountId: params.requester.accountId,
});
const policy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel,
accountId,
kind: "subagent",
});
if (!policy.enabled) {
return {
ok: false,
error: formatThreadBindingDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "subagent",
}),
};
}
if (!policy.spawnEnabled) {
return {
ok: false,
error: formatThreadBindingSpawnDisabledError({
channel: policy.channel,
accountId: policy.accountId,
kind: "subagent",
}),
};
}
const bindingService = getSessionBindingService();
const capabilities = bindingService.getCapabilities({
channel: policy.channel,
accountId: policy.accountId,
});
if (!capabilities.adapterAvailable) {
return {
ok: false,
error: buildThreadBindingUnavailableError(params.mode),
};
}
const pluginPlacement = resolveChannelDefaultBindingPlacement(policy.channel);
const placementToUse =
pluginPlacement ??
resolvePlacementWithoutChannelPlugin({
capabilities,
});
if (!capabilities.bindSupported || !capabilities.placements.includes(placementToUse)) {
return {
ok: false,
error: `Thread bindings do not support ${placementToUse} placement for ${policy.channel}.`,
};
}
const fallbackConversationRef = resolveConversationRefForThreadBinding({
cfg: params.cfg,
channel: policy.channel,
accountId: policy.accountId,
to: params.requester.to,
threadId: params.requester.threadId,
});
const requesterConversationRef = resolveRequesterBoundConversationRef({
requesterSessionKey: params.requesterSessionKey,
channel: policy.channel,
accountId: policy.accountId,
fallback: fallbackConversationRef,
});
if (requesterConversationRef === null) {
return {
ok: false,
error: `Could not resolve a unique ${policy.channel} requester conversation for subagent thread spawn.`,
};
}
const conversationRef = requesterConversationRef ?? fallbackConversationRef;
if (!conversationRef?.conversationId) {
return {
ok: false,
error: `Could not resolve a ${policy.channel} conversation for subagent thread spawn.`,
};
}
return {
ok: true,
binding: {
channel: policy.channel,
accountId: policy.accountId,
placement: placementToUse,
conversationId: conversationRef.conversationId,
...(conversationRef.parentConversationId
? { parentConversationId: conversationRef.parentConversationId }
: {}),
},
};
}
async function bindThreadForSubagentSpawn(params: {
cfg: OpenClawConfig;
childSessionKey: string;
agentId: string;
label?: string;
@@ -656,51 +890,70 @@ async function ensureThreadBindingForSubagentSpawn(params: {
threadId?: string | number;
};
}): Promise<
{ status: "ok"; deliveryOrigin?: DeliveryContext } | { status: "error"; error: string }
| { status: "ok"; deliveryOrigin?: DeliveryContext }
| {
status: "error";
error: string;
}
> {
if (!params.hookRunner?.hasHooks("subagent_spawning")) {
const prepared = prepareSubagentThreadBinding({
cfg: params.cfg,
mode: params.mode,
requesterSessionKey: params.requesterSessionKey,
requester: params.requester,
});
if (!prepared.ok) {
return {
status: "error",
error: buildThreadBindingUnavailableError(params.mode),
error: prepared.error,
};
}
try {
const result = await params.hookRunner.runSubagentSpawning(
{
childSessionKey: params.childSessionKey,
const binding = await getSessionBindingService().bind({
targetSessionKey: params.childSessionKey,
targetKind: "subagent",
conversation: {
channel: prepared.binding.channel,
accountId: prepared.binding.accountId,
conversationId: prepared.binding.conversationId,
...(prepared.binding.parentConversationId
? { parentConversationId: prepared.binding.parentConversationId }
: {}),
},
placement: prepared.binding.placement,
metadata: {
threadName: resolveThreadBindingThreadName({
agentId: params.agentId,
label: params.label || params.agentId,
}),
agentId: params.agentId,
label: params.label,
mode: params.mode,
requester: params.requester,
threadRequested: true,
label: params.label || undefined,
boundBy: "system",
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label: params.label || undefined,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
cfg: params.cfg,
channel: prepared.binding.channel,
accountId: prepared.binding.accountId,
}),
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
cfg: params.cfg,
channel: prepared.binding.channel,
accountId: prepared.binding.accountId,
}),
}),
},
{
childSessionKey: params.childSessionKey,
requesterSessionKey: params.requesterSessionKey,
},
);
if (result?.status === "error") {
const error = result.error.trim();
return {
status: "error",
error: error || "Failed to prepare thread binding for this subagent session.",
};
}
if (!result) {
return {
status: "error",
error: buildThreadBindingUnavailableError(params.mode),
};
}
if (result?.status !== "ok" || !result.threadBindingReady) {
});
if (!binding.conversation.conversationId) {
return {
status: "error",
error:
"Unable to create or bind a thread for this subagent session. Session mode is unavailable for this target.",
};
}
const deliveryOrigin = normalizeDeliveryContext(result.deliveryOrigin);
const deliveryOrigin = routeToDeliveryFields(routeFromBindingRecord(binding)).deliveryContext;
return {
status: "ok",
...(deliveryOrigin ? { deliveryOrigin } : {}),
@@ -1030,8 +1283,8 @@ export async function spawnSubagentDirect(
modelApplied = true;
}
if (requestThreadBinding) {
const bindResult = await ensureThreadBindingForSubagentSpawn({
hookRunner,
const bindResult = await bindThreadForSubagentSpawn({
cfg,
childSessionKey,
agentId: targetAgentId,
label: label || undefined,
+33 -7
View File
@@ -18,6 +18,18 @@ type TestConfig = {
list?: TestAgentConfig[];
};
};
type TestBindingRequest = {
targetSessionKey: string;
targetKind?: string;
conversation: {
channel: string;
accountId?: string;
conversationId: string;
parentConversationId?: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
};
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
@@ -28,7 +40,23 @@ const hoisted = vi.hoisted(() => ({
>(() => ({ sandboxed: false })),
hookRunner: {
hasHooks: vi.fn(() => false),
runSubagentSpawning: vi.fn(),
},
bindingService: {
getCapabilities: vi.fn(() => ({
adapterAvailable: true,
bindSupported: true,
placements: ["child"] as Array<"current" | "child">,
})),
bind: vi.fn(async (request: TestBindingRequest) => {
const conversation = request.conversation;
return {
targetSessionKey: request.targetSessionKey,
targetKind: request.targetKind,
status: "active",
conversation,
};
}),
listBySession: vi.fn(() => []),
},
}));
@@ -111,6 +139,7 @@ describe("spawnSubagentDirect workspace inheritance", () => {
resolveAgentConfig: resolveTestAgentConfig,
resolveAgentWorkspaceDir: resolveTestAgentWorkspace,
resolveSandboxRuntimeStatus: hoisted.resolveSandboxRuntimeStatusMock,
getSessionBindingService: () => hoisted.bindingService,
resetModules: false,
}));
});
@@ -123,7 +152,9 @@ describe("spawnSubagentDirect workspace inheritance", () => {
hoisted.resolveSandboxRuntimeStatusMock.mockImplementation(() => ({ sandboxed: false }));
hoisted.hookRunner.hasHooks.mockReset();
hoisted.hookRunner.hasHooks.mockImplementation(() => false);
hoisted.hookRunner.runSubagentSpawning.mockReset();
hoisted.bindingService.getCapabilities.mockClear();
hoisted.bindingService.bind.mockClear();
hoisted.bindingService.listBySession.mockClear();
hoisted.configOverride = createConfigOverride();
setupAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
});
@@ -323,11 +354,6 @@ describe("spawnSubagentDirect workspace inheritance", () => {
});
it("keeps lifecycle hooks enabled when registerSubagentRun fails after thread binding succeeds", async () => {
hoisted.hookRunner.hasHooks.mockImplementation((name?: string) => name === "subagent_spawning");
hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
status: "ok",
threadBindingReady: true,
});
hoisted.registerSubagentRunMock.mockImplementation(() => {
throw new Error("registry unavailable");
});
+5
View File
@@ -163,6 +163,11 @@ const knownDeprecatedSurfaceMarkers = [
file: "src/plugins/hook-types.ts",
marker: "@deprecated Use gateway_stop",
},
{
code: "legacy-subagent-spawning-hook",
file: "src/plugins/hook-types.ts",
marker: "@deprecated Core prepares thread-bound subagent bindings",
},
{
code: "deprecated-memory-embedding-provider-api",
file: "src/plugins/types.ts",
+22
View File
@@ -39,6 +39,28 @@ export const PLUGIN_COMPAT_RECORDS = [
releaseNote:
'`api.on("deactivate", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.',
},
{
code: "legacy-subagent-spawning-hook",
status: "deprecated",
owner: "sdk",
introduced: "2026-05-30",
deprecated: "2026-05-30",
warningStarts: "2026-05-30",
removeAfter: "2026-08-30",
replacement:
"`subagent_spawned` for post-launch observation; core session-binding adapters for thread routing",
docsPath: "/plugins/hooks#upcoming-deprecations",
surfaces: [
'api.on("subagent_spawning", ...)',
"PluginHookSubagentSpawningEvent",
"PluginHookSubagentSpawningResult",
"SubagentLifecycleHookRunner.runSubagentSpawning",
],
diagnostics: ["plugin runtime compatibility warning"],
tests: ["src/plugins/loader.test.ts", "src/plugins/compat/registry.test.ts"],
releaseNote:
'`api.on("subagent_spawning", ...)` remains wired only for older plugins; core now owns thread-bound subagent routing.',
},
{
code: "hook-only-plugin-shape",
status: "active",
@@ -28,21 +28,9 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
"extensions/active-memory/index.ts": ["before_prompt_build"],
"extensions/codex/index.ts": ["inbound_claim"],
"extensions/diffs/src/plugin.ts": ["before_prompt_build"],
"extensions/discord/subagent-hooks-api.ts": [
"subagent_delivery_target",
"subagent_ended",
"subagent_spawning",
],
"extensions/feishu/subagent-hooks-api.ts": [
"subagent_delivery_target",
"subagent_ended",
"subagent_spawning",
],
"extensions/matrix/subagent-hooks-api.ts": [
"subagent_delivery_target",
"subagent_ended",
"subagent_spawning",
],
"extensions/discord/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
"extensions/feishu/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
"extensions/matrix/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
"extensions/memory-core/src/dreaming.ts": ["before_agent_reply", "gateway_start", "gateway_stop"],
"extensions/memory-lancedb/index.ts": ["agent_end", "before_prompt_build", "session_end"],
"extensions/thread-ownership/index.ts": ["message_received", "message_sending"],
+52
View File
@@ -91,6 +91,11 @@ export type PluginHookName =
| "before_message_write"
| "session_start"
| "session_end"
/**
* @deprecated Core prepares thread-bound subagent bindings through channel
* session-binding adapters before `subagent_spawned` fires. Use
* `subagent_spawned` for post-launch observation in new plugins.
*/
| "subagent_spawning"
| "subagent_delivery_target"
| "subagent_spawned"
@@ -152,6 +157,38 @@ type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? tru
const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true;
void assertAllPluginHookNamesListed;
export type DeprecatedPluginHookName = "subagent_spawning" | "deactivate";
export type PluginHookDeprecation = {
replacement: string;
reason: string;
removeAfter?: string;
};
export const DEPRECATED_PLUGIN_HOOKS = {
subagent_spawning: {
replacement: "`subagent_spawned` for observation; core session bindings for routing",
reason:
"Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires.",
removeAfter: "2026-08-30",
},
deactivate: {
replacement: "`gateway_stop`",
reason: "`deactivate` is a legacy cleanup hook alias for `gateway_stop`.",
removeAfter: "2026-08-16",
},
} as const satisfies Record<DeprecatedPluginHookName, PluginHookDeprecation>;
export const DEPRECATED_PLUGIN_HOOK_NAMES = Object.keys(
DEPRECATED_PLUGIN_HOOKS,
) as DeprecatedPluginHookName[];
const deprecatedPluginHookNameSet = new Set<PluginHookName>(DEPRECATED_PLUGIN_HOOK_NAMES);
export const isDeprecatedPluginHookName = (
hookName: PluginHookName,
): hookName is DeprecatedPluginHookName => deprecatedPluginHookNameSet.has(hookName);
const pluginHookNameSet = new Set<PluginHookName>(PLUGIN_HOOK_NAMES);
export const isPluginHookName = (hookName: unknown): hookName is PluginHookName =>
@@ -612,8 +649,18 @@ type PluginHookSubagentSpawnBase = {
threadRequested: boolean;
};
/**
* @deprecated Core prepares thread-bound subagent bindings through channel
* session-binding adapters before `subagent_spawned` fires. Use
* `subagent_spawned` for post-launch observation in new plugins.
*/
export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase;
/**
* @deprecated Core prepares thread-bound subagent bindings through channel
* session-binding adapters before `subagent_spawned` fires. Returning routing
* data from `subagent_spawning` is retained only for older runtimes.
*/
export type PluginHookSubagentSpawningResult =
| {
status: "ok";
@@ -1040,6 +1087,11 @@ export type PluginHookHandlerMap = {
event: PluginHookSessionEndEvent,
ctx: PluginHookSessionContext,
) => Promise<void> | void;
/**
* @deprecated Core prepares thread-bound subagent bindings through channel
* session-binding adapters before `subagent_spawned` fires. Use
* `subagent_spawned` for post-launch observation in new plugins.
*/
subagent_spawning: (
event: PluginHookSubagentSpawningEvent,
ctx: PluginHookSubagentContext,
+3 -2
View File
@@ -1453,8 +1453,9 @@ export function createHookRunner(
}
/**
* Run subagent_spawning hook.
* Runs sequentially so channel plugins can deterministically provision session bindings.
* @deprecated Core prepares thread-bound subagent bindings through channel
* session-binding adapters before subagent_spawned fires. This remains only
* for older plugins that call the hook runner directly.
*/
async function runSubagentSpawning(
event: PluginHookSubagentSpawningEvent,
+39 -2
View File
@@ -15,7 +15,11 @@ import {
getRegisteredEventKeys,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
import {
emitDiagnosticEvent,
resetDiagnosticEventsForTest,
waitForDiagnosticEventsDrained,
} from "../infra/diagnostic-events.js";
import {
clearDetachedTaskLifecycleRuntimeRegistration,
getDetachedTaskLifecycleRuntimeRegistration,
@@ -985,6 +989,7 @@ function collectStartupTraceMetrics(
}
afterEach(() => {
resetDiagnosticEventsForTest();
clearRuntimeConfigSnapshot();
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
resetPluginLoaderTestStateForTest();
@@ -6862,6 +6867,37 @@ module.exports = {
).toBe(true);
});
it("warns when plugins register deprecated subagent_spawning typed hooks", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "legacy-subagent-spawning-hook",
filename: "legacy-subagent-spawning-hook.cjs",
body: `module.exports = { id: "legacy-subagent-spawning-hook", register(api) {
api.on("subagent_spawning", () => ({ status: "ok" }));
} };`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: {
allow: ["legacy-subagent-spawning-hook"],
},
});
expect(
registry.plugins.find((entry) => entry.id === "legacy-subagent-spawning-hook")?.status,
).toBe("loaded");
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["subagent_spawning"]);
expect(
registry.diagnostics.some(
(diag) =>
diag.pluginId === "legacy-subagent-spawning-hook" &&
diag.message ===
'typed hook "subagent_spawning" is deprecated (legacy-subagent-spawning-hook); Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires. Use `subagent_spawned` for observation; core session bindings for routing. This compatibility hook will be removed after 2026-08-30.',
),
).toBe(true);
});
it("ignores unknown typed hooks from plugins and keeps loading", () => {
useNoBundledPlugins();
const plugin = writePlugin({
@@ -8059,7 +8095,7 @@ module.exports = {
).toBe("loaded");
});
it("supports legacy plugins subscribing to diagnostic events from the root sdk", () => {
it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => {
useNoBundledPlugins();
const seenKey = "__openclawLegacyRootDiagnosticSeen";
delete (globalThis as Record<string, unknown>)[seenKey];
@@ -8114,6 +8150,7 @@ module.exports = {
sessionKey: "agent:main:test:dm:peer",
usage: { total: 1 },
});
await waitForDiagnosticEventsDrained();
expect((globalThis as Record<string, unknown>)[seenKey]).toEqual([
{
+29
View File
@@ -152,7 +152,9 @@ import {
normalizePluginToolNames,
} from "./tool-contracts.js";
import {
DEPRECATED_PLUGIN_HOOKS,
isConversationHookName,
isDeprecatedPluginHookName,
isPluginHookName,
isPromptInjectionHookName,
stripPromptMutationFieldsFromLegacyHookResult,
@@ -202,6 +204,7 @@ export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistrati
const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request";
const LEGACY_DEACTIVATE_HOOK_ALIAS_COMPAT = getPluginCompatRecord("legacy-deactivate-hook-alias");
const LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT = getPluginCompatRecord("legacy-subagent-spawning-hook");
function formatLegacyDeactivateHookAliasDiagnostic(): string {
const removeAfter =
@@ -212,6 +215,22 @@ function formatLegacyDeactivateHookAliasDiagnostic(): string {
);
}
function formatDeprecatedTypedHookDiagnostic(hookName: PluginHookName): string | undefined {
if (!isDeprecatedPluginHookName(hookName) || hookName === "deactivate") {
return undefined;
}
const deprecation = DEPRECATED_PLUGIN_HOOKS[hookName];
const compat =
hookName === "subagent_spawning" ? LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT : undefined;
const removeAfter = compat?.removeAfter ?? deprecation.removeAfter ?? "a future breaking release";
const code = compat?.code ?? "deprecated-plugin-hook";
return (
`typed hook "${hookName}" is deprecated (${code}); ` +
`${deprecation.reason} Use ${deprecation.replacement}. ` +
`This compatibility hook will be removed after ${removeAfter}.`
);
}
type PluginOwnedProviderRegistration<T extends { id: string }> = {
pluginId: string;
pluginName?: string;
@@ -2444,6 +2463,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
source: record.source,
message: formatLegacyDeactivateHookAliasDiagnostic(),
});
} else {
const deprecatedHookDiagnostic = formatDeprecatedTypedHookDiagnostic(hookName);
if (deprecatedHookDiagnostic) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: deprecatedHookDiagnostic,
});
}
}
let effectiveHandler = handler;
if (policy?.allowPromptInjection === false && isPromptInjectionHookName(effectiveHookName)) {