mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 20:13:43 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user