From 3fc0df953cf17b8bc47e45250c84965765cfed76 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 21:19:09 +0100 Subject: [PATCH] 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 --- docs/plugins/hooks.md | 8 +- docs/plugins/sdk-migration.md | 29 ++ docs/tools/subagents.md | 14 +- extensions/discord/src/subagent-hooks.test.ts | 6 +- extensions/discord/subagent-hooks-api.ts | 4 - extensions/feishu/src/subagent-hooks.test.ts | 6 +- extensions/feishu/subagent-hooks-api.ts | 4 - extensions/matrix/index.test.ts | 3 - .../matrix/src/matrix/subagent-hooks.test.ts | 5 +- extensions/matrix/subagent-hooks-api.ts | 4 - ...subagents.sessions-spawn.lifecycle.test.ts | 17 +- src/agents/sessions-spawn-hooks.test.ts | 162 +++++---- ...ent-spawn.mode-session-diagnostics.test.ts | 18 +- src/agents/subagent-spawn.runtime.ts | 1 + src/agents/subagent-spawn.test-helpers.ts | 49 ++- .../subagent-spawn.thread-binding.test.ts | 177 ++++++---- src/agents/subagent-spawn.ts | 325 ++++++++++++++++-- src/agents/subagent-spawn.workspace.test.ts | 40 ++- src/plugins/compat/registry.test.ts | 5 + src/plugins/compat/registry.ts | 22 ++ .../contracts/boundary-invariants.test.ts | 18 +- src/plugins/hook-types.ts | 52 +++ src/plugins/hooks.ts | 5 +- src/plugins/loader.test.ts | 41 ++- src/plugins/registry.ts | 29 ++ 25 files changed, 796 insertions(+), 248 deletions(-) diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md index 6c5523fff7b..54e14a0cf4a 100644 --- a/docs/plugins/hooks.md +++ b/docs/plugins/hooks.md @@ -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 diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 364c551241d..00d447b7a68 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -792,6 +792,35 @@ canonical replacement. + + **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. + + + Four discovery type aliases are now thin wrappers over the catalog-era types: diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 00760141945..806e2d5ab49 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -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 diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index bac2928a3f3..04ed9a82b6c 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -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({ config, - register: registerDiscordSubagentHooks, + register: (api) => { + registerDiscordSubagentHooks(api); + api.on("subagent_spawning", (event) => handleDiscordSubagentSpawning(api, event)); + }, }); } diff --git a/extensions/discord/subagent-hooks-api.ts b/extensions/discord/subagent-hooks-api.ts index 0a9b4e3d0d1..d6a53671170 100644 --- a/extensions/discord/subagent-hooks-api.ts +++ b/extensions/discord/subagent-hooks-api.ts @@ -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); diff --git a/extensions/feishu/src/subagent-hooks.test.ts b/extensions/feishu/src/subagent-hooks.test.ts index 44910742ad2..2b0fb36d12e 100644 --- a/extensions/feishu/src/subagent-hooks.test.ts +++ b/extensions/feishu/src/subagent-hooks.test.ts @@ -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 = baseConfig) { return registerHookHandlersForTest({ config, - register: registerFeishuSubagentHooks, + register: (api) => { + registerFeishuSubagentHooks(api); + api.on("subagent_spawning", (event, ctx) => handleFeishuSubagentSpawning(event, ctx)); + }, }); } diff --git a/extensions/feishu/subagent-hooks-api.ts b/extensions/feishu/subagent-hooks-api.ts index 1292188d9c3..bcb05bca6a2 100644 --- a/extensions/feishu/subagent-hooks-api.ts +++ b/extensions/feishu/subagent-hooks-api.ts @@ -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); diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index bb6551d754d..beaaeaf6ba7 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -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" }); }); diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts index 273bb873481..357834dba75 100644 --- a/extensions/matrix/src/matrix/subagent-hooks.test.ts +++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts @@ -55,7 +55,10 @@ const fakeApi = { config: {} } as never; function registerHandlersForTest(config: Record = {}) { return registerHookHandlersForTest({ config, - register: registerMatrixSubagentHooks, + register: (api) => { + registerMatrixSubagentHooks(api); + api.on("subagent_spawning", (event) => handleMatrixSubagentSpawning(api, event)); + }, }); } diff --git a/extensions/matrix/subagent-hooks-api.ts b/extensions/matrix/subagent-hooks-api.ts index 39ba6e7a8e2..4254dcef7bc 100644 --- a/extensions/matrix/subagent-hooks-api.ts +++ b/extensions/matrix/subagent-hooks-api.ts @@ -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); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 9953befb895..ea66b66ceb6 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -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, diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index 712c6efabf9..6cdf0e5ef42 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -5,6 +5,18 @@ import { } from "./subagent-spawn.test-helpers.js"; type GatewayRequest = { method?: string; params?: Record }; +type TestBindingRequest = { + targetSessionKey: string; + targetKind?: string; + conversation: { + channel: string; + accountId?: string; + conversationId: string; + parentConversationId?: string; + }; + placement: "current" | "child"; + metadata?: Record; +}; 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, Record]; - 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(); }); diff --git a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts index 0474b42471b..d73b87d7394 100644 --- a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts +++ b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts @@ -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[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(); }); diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index a6def7dd68e..61269fd6f25 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -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, diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index 4d2fa8404b5..098b6a3db75 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -9,8 +9,13 @@ type MockImplementationTarget = { }; type SessionStore = Record>; type SessionStoreMutator = (store: SessionStore) => unknown; -type HookRunner = Pick & - Partial>; +type HookRunner = Pick & + Partial< + Pick< + SubagentLifecycleHookRunner, + "runSubagentSpawning" | "runSubagentSpawned" | "runSubagentEnded" + > + >; type SubagentSpawnModuleForTest = Awaited & { 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; + }) => 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 }) => ({ diff --git a/src/agents/subagent-spawn.thread-binding.test.ts b/src/agents/subagent-spawn.thread-binding.test.ts index aa86310ce2c..59401378efd 100644 --- a/src/agents/subagent-spawn.thread-binding.test.ts +++ b/src/agents/subagent-spawn.thread-binding.test.ts @@ -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>; + 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[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; let currentSessionBindingService: ReturnType; 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> = []; + currentSessionBindingService = { + getCapabilities: () => ({ + adapterAvailable: true, + bindSupported: true, + placements: ["child"], + }), + bind: async (request) => { + bindCalls.push(request as unknown as Record); + 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 } | 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 } | 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"); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index c408a7cdbcf..5123b45991f 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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; + 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, diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index 3f3c29bcec4..fb1ce13f56e 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -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; +}; 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"); }); diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 942bdc95ccc..3d6cfe9a731 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -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", diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index aa3a0a803e9..f7f568da99b 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -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", diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 1a1df99a569..8a6c0a69246 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -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"], diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 8793c3fe266..fed859a696b 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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; + +export const DEPRECATED_PLUGIN_HOOK_NAMES = Object.keys( + DEPRECATED_PLUGIN_HOOKS, +) as DeprecatedPluginHookName[]; + +const deprecatedPluginHookNameSet = new Set(DEPRECATED_PLUGIN_HOOK_NAMES); + +export const isDeprecatedPluginHookName = ( + hookName: PluginHookName, +): hookName is DeprecatedPluginHookName => deprecatedPluginHookNameSet.has(hookName); + const pluginHookNameSet = new Set(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; + /** + * @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, diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts index 1914cb4e0f2..8a05bcca166 100644 --- a/src/plugins/hooks.ts +++ b/src/plugins/hooks.ts @@ -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, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 00fd62d4cbd..cfd210bf6d4 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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)[seenKey]; @@ -8114,6 +8150,7 @@ module.exports = { sessionKey: "agent:main:test:dm:peer", usage: { total: 1 }, }); + await waitForDiagnosticEventsDrained(); expect((globalThis as Record)[seenKey]).toEqual([ { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0d75775afbb..5b27cf6d4a1 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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 = { 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)) {