mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 15:02:58 +00:00
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:
committed by
GitHub
parent
4ac90a5b48
commit
3fc0df953c
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user