feat: thread-bound subagents on Discord (#21805)

* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
Onur
2026-02-21 16:14:55 +01:00
committed by GitHub
parent 166068dfbe
commit 8178ea472d
114 changed files with 12214 additions and 1659 deletions

View File

@@ -48,6 +48,7 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
import { chunkItems } from "../../utils/chunk-items.js";
import { withTimeout } from "../../utils/with-timeout.js";
@@ -80,6 +81,7 @@ import {
type DiscordModelPickerCommandContext,
} from "./model-picker.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
import type { ThreadBindingManager } from "./thread-bindings.js";
import { resolveDiscordThreadParentInfo } from "./threading.js";
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
@@ -268,6 +270,7 @@ type DiscordCommandArgContext = {
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
};
type DiscordModelPickerContext = DiscordCommandArgContext;
@@ -353,6 +356,7 @@ async function resolveDiscordModelPickerRoute(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
threadBindings: ThreadBindingManager;
}) {
const { interaction, cfg, accountId } = params;
const channel = interaction.channel;
@@ -383,7 +387,7 @@ async function resolveDiscordModelPickerRoute(params: {
threadParentId = parentInfo.id;
}
return resolveAgentRoute({
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId,
@@ -395,6 +399,19 @@ async function resolveDiscordModelPickerRoute(params: {
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
const threadBinding = isThreadChannel
? params.threadBindings.getByThreadId(rawChannelId)
: undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
return boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
}
function resolveDiscordModelPickerCurrentModel(params: {
@@ -436,6 +453,7 @@ async function replyWithDiscordModelPickerProviders(params: {
command: DiscordModelPickerCommandContext;
userId: string;
accountId: string;
threadBindings: ThreadBindingManager;
preferFollowUp: boolean;
}) {
const data = await loadDiscordModelPickerData(params.cfg);
@@ -443,6 +461,7 @@ async function replyWithDiscordModelPickerProviders(params: {
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
threadBindings: params.threadBindings,
});
const currentModel = resolveDiscordModelPickerCurrentModel({
cfg: params.cfg,
@@ -603,6 +622,7 @@ async function handleDiscordModelPickerInteraction(
interaction,
cfg: ctx.cfg,
accountId: ctx.accountId,
threadBindings: ctx.threadBindings,
});
const currentModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
@@ -827,6 +847,7 @@ async function handleDiscordModelPickerInteraction(
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
threadBindings: ctx.threadBindings,
suppressReplies: true,
}),
12000,
@@ -957,6 +978,7 @@ async function handleDiscordCommandArgInteraction(
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
threadBindings: ctx.threadBindings,
});
}
@@ -968,6 +990,7 @@ class DiscordCommandArgButton extends Button {
private discordConfig: DiscordConfig;
private accountId: string;
private sessionPrefix: string;
private threadBindings: ThreadBindingManager;
constructor(params: {
label: string;
@@ -976,6 +999,7 @@ class DiscordCommandArgButton extends Button {
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
}) {
super();
this.label = params.label;
@@ -984,6 +1008,7 @@ class DiscordCommandArgButton extends Button {
this.discordConfig = params.discordConfig;
this.accountId = params.accountId;
this.sessionPrefix = params.sessionPrefix;
this.threadBindings = params.threadBindings;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
@@ -992,6 +1017,7 @@ class DiscordCommandArgButton extends Button {
discordConfig: this.discordConfig,
accountId: this.accountId,
sessionPrefix: this.sessionPrefix,
threadBindings: this.threadBindings,
});
}
}
@@ -1067,6 +1093,7 @@ function buildDiscordCommandArgMenu(params: {
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
threadBindings: ThreadBindingManager;
}): { content: string; components: Row<Button>[] } {
const { command, menu, interaction } = params;
const commandLabel = command.nativeName ?? command.key;
@@ -1086,6 +1113,7 @@ function buildDiscordCommandArgMenu(params: {
discordConfig: params.discordConfig,
accountId: params.accountId,
sessionPrefix: params.sessionPrefix,
threadBindings: params.threadBindings,
}),
);
return new Row(buttons);
@@ -1102,8 +1130,17 @@ export function createDiscordNativeCommand(params: {
accountId: string;
sessionPrefix: string;
ephemeralDefault: boolean;
threadBindings: ThreadBindingManager;
}): Command {
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
const {
command,
cfg,
discordConfig,
accountId,
sessionPrefix,
ephemeralDefault,
threadBindings,
} = params;
const commandDefinition =
findCommandByNativeName(command.name, "discord") ??
({
@@ -1164,6 +1201,7 @@ export function createDiscordNativeCommand(params: {
accountId,
sessionPrefix,
preferFollowUp: false,
threadBindings,
});
}
})();
@@ -1179,6 +1217,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId: string;
sessionPrefix: string;
preferFollowUp: boolean;
threadBindings: ThreadBindingManager;
suppressReplies?: boolean;
}) {
const {
@@ -1191,6 +1230,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId,
sessionPrefix,
preferFollowUp,
threadBindings,
suppressReplies,
} = params;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
@@ -1391,6 +1431,7 @@ async function dispatchDiscordCommandInteraction(params: {
discordConfig,
accountId,
sessionPrefix,
threadBindings,
});
if (preferFollowUp) {
await safeDiscordInteractionCall("interaction follow-up", () =>
@@ -1423,6 +1464,7 @@ async function dispatchDiscordCommandInteraction(params: {
command: pickerCommandContext,
userId: user.id,
accountId,
threadBindings,
preferFollowUp,
});
return;
@@ -1443,6 +1485,16 @@ async function dispatchDiscordCommandInteraction(params: {
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
@@ -1461,9 +1513,9 @@ async function dispatchDiscordCommandInteraction(params: {
? `discord:group:${channelId}`
: `discord:channel:${channelId}`,
To: `slash:${user.id}`,
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: route.sessionKey,
AccountId: route.accountId,
SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey,
AccountId: effectiveRoute.accountId,
ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
GroupSubject: isGuild ? interaction.guild?.name : undefined,
@@ -1496,6 +1548,7 @@ async function dispatchDiscordCommandInteraction(params: {
Surface: "discord" as const,
WasMentioned: true,
MessageSid: interactionId,
MessageThreadId: isThreadChannel ? channelId : undefined,
Timestamp: Date.now(),
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
@@ -1508,11 +1561,11 @@ async function dispatchDiscordCommandInteraction(params: {
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
agentId: effectiveRoute.agentId,
channel: "discord",
accountId: route.accountId,
accountId: effectiveRoute.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
let didReply = false;
await dispatchReplyWithDispatcher({
@@ -1520,7 +1573,7 @@ async function dispatchDiscordCommandInteraction(params: {
cfg,
dispatcherOptions: {
...prefixOptions,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId),
deliver: async (payload) => {
if (suppressReplies) {
return;