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

@@ -25,6 +25,7 @@ import {
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js";
import {
@@ -55,6 +56,10 @@ import {
} from "./message-utils.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js";
import {
isRecentlyUnboundThreadWebhookMessage,
type ThreadBindingRecord,
} from "./thread-bindings.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
export type {
@@ -62,6 +67,41 @@ export type {
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
isBoundThreadSession: boolean;
}): boolean {
if (!params.shouldRequireMention) {
return false;
}
return !params.isBoundThreadSession;
}
export function shouldIgnoreBoundThreadWebhookMessage(params: {
accountId?: string;
threadId?: string;
webhookId?: string | null;
threadBinding?: ThreadBindingRecord;
}): boolean {
const webhookId = params.webhookId?.trim() || "";
if (!webhookId) {
return false;
}
const boundWebhookId = params.threadBinding?.webhookId?.trim() || "";
if (!boundWebhookId) {
const threadId = params.threadId?.trim() || "";
if (!threadId) {
return false;
}
return isRecentlyUnboundThreadWebhookMessage({
accountId: params.accountId,
threadId,
webhookId,
});
}
return webhookId === boundWebhookId;
}
export async function preflightDiscordMessage(
params: DiscordMessagePreflightParams,
): Promise<DiscordMessagePreflightContext | null> {
@@ -253,7 +293,30 @@ export async function preflightDiscordMessage(
// Pass parent peer for thread binding inheritance
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
});
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
const threadBinding = earlyThreadChannel
? params.threadBindings.getByThreadId(messageChannelId)
: undefined;
if (
shouldIgnoreBoundThreadWebhookMessage({
accountId: params.accountId,
threadId: messageChannelId,
webhookId,
threadBinding,
})
) {
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
return null;
}
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
);
@@ -314,7 +377,7 @@ export async function preflightDiscordMessage(
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const baseSessionKey = route.sessionKey;
const baseSessionKey = effectiveRoute.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfigWithFallback({
guildInfo,
@@ -408,7 +471,7 @@ export async function preflightDiscordMessage(
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMention = resolveDiscordShouldRequireMention({
const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
botId,
@@ -416,6 +479,11 @@ export async function preflightDiscordMessage(
channelConfig,
guildInfo,
});
const isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
const shouldRequireMention = resolvePreflightMentionRequirement({
shouldRequireMention: shouldRequireMentionByConfig,
isBoundThreadSession,
});
// Preflight audio transcription for mention detection in guilds
// This allows voice notes to be checked for mentions before being dropped
@@ -547,7 +615,7 @@ export async function preflightDiscordMessage(
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
logDebug(
`[discord-preflight] shouldRequireMention=${shouldRequireMention} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
);
if (isGuildMessage && shouldRequireMention) {
if (botId && mentionGate.shouldSkip) {
@@ -586,7 +654,7 @@ export async function preflightDiscordMessage(
if (systemText) {
logDebug(`[discord-preflight] drop: system event`);
enqueueSystemEvent(systemText, {
sessionKey: route.sessionKey,
sessionKey: effectiveRoute.sessionKey,
contextKey: `discord:system:${messageChannelId}:${message.id}`,
});
return null;
@@ -598,7 +666,9 @@ export async function preflightDiscordMessage(
return null;
}
logDebug(`[discord-preflight] success: route=${route.agentId} sessionKey=${route.sessionKey}`);
logDebug(
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,
);
return {
cfg: params.cfg,
discordConfig: params.discordConfig,
@@ -628,7 +698,10 @@ export async function preflightDiscordMessage(
baseText,
messageText,
wasMentioned,
route,
route: effectiveRoute,
threadBinding,
boundSessionKey: boundSessionKey || undefined,
boundAgentId,
guildInfo,
guildSlug,
threadChannel,
@@ -651,5 +724,6 @@ export async function preflightDiscordMessage(
effectiveWasMentioned,
canDetectMention,
historyEntry,
threadBindings: params.threadBindings,
};
}