mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 03:01:25 +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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user