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

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",

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",

View File

@@ -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"],

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,

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,

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([
{

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)) {