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

@@ -1,11 +1,104 @@
import type { RequestClient } from "@buape/carbon";
import { resolveAgentAvatar } from "../../agents/identity-avatar.js";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { loadConfig } from "../../config/config.js";
import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { sendMessageDiscord, sendVoiceMessageDiscord } from "../send.js";
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
function resolveTargetChannelId(target: string): string | undefined {
if (!target.startsWith("channel:")) {
return undefined;
}
const channelId = target.slice("channel:".length).trim();
return channelId || undefined;
}
function resolveBoundThreadBinding(params: {
threadBindings?: ThreadBindingManager;
sessionKey?: string;
target: string;
}): ThreadBindingRecord | undefined {
const sessionKey = params.sessionKey?.trim();
if (!params.threadBindings || !sessionKey) {
return undefined;
}
const bindings = params.threadBindings.listBySessionKey(sessionKey);
if (bindings.length === 0) {
return undefined;
}
const targetChannelId = resolveTargetChannelId(params.target);
if (!targetChannelId) {
return undefined;
}
return bindings.find((entry) => entry.threadId === targetChannelId);
}
function resolveBindingPersona(binding: ThreadBindingRecord | undefined): {
username?: string;
avatarUrl?: string;
} {
if (!binding) {
return {};
}
const baseLabel = binding.label?.trim() || binding.agentId;
const username = (`🤖 ${baseLabel}`.trim() || "🤖 agent").slice(0, 80);
let avatarUrl: string | undefined;
try {
const avatar = resolveAgentAvatar(loadConfig(), binding.agentId);
if (avatar.kind === "remote") {
avatarUrl = avatar.url;
}
} catch {
avatarUrl = undefined;
}
return { username, avatarUrl };
}
async function sendDiscordChunkWithFallback(params: {
target: string;
text: string;
token: string;
accountId?: string;
rest?: RequestClient;
replyTo?: string;
binding?: ThreadBindingRecord;
username?: string;
avatarUrl?: string;
}) {
const text = params.text.trim();
if (!text) {
return;
}
const binding = params.binding;
if (binding?.webhookId && binding?.webhookToken) {
try {
await sendWebhookMessageDiscord(text, {
webhookId: binding.webhookId,
webhookToken: binding.webhookToken,
accountId: binding.accountId,
threadId: binding.threadId,
replyTo: params.replyTo,
username: params.username,
avatarUrl: params.avatarUrl,
});
return;
} catch {
// Fall through to the standard bot sender path.
}
}
await sendMessageDiscord(params.target, text, {
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo: params.replyTo,
});
}
export async function deliverDiscordReply(params: {
replies: ReplyPayload[];
@@ -20,6 +113,8 @@ export async function deliverDiscordReply(params: {
replyToMode?: ReplyToMode;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
sessionKey?: string;
threadBindings?: ThreadBindingManager;
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
const replyTo = params.replyToId?.trim() || undefined;
@@ -40,6 +135,12 @@ export async function deliverDiscordReply(params: {
replyUsed = true;
return replyTo;
};
const binding = resolveBoundThreadBinding({
threadBindings: params.threadBindings,
sessionKey: params.sessionKey,
target: params.target,
});
const persona = resolveBindingPersona(binding);
for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? "";
@@ -59,16 +160,20 @@ export async function deliverDiscordReply(params: {
chunks.push(text);
}
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) {
if (!chunk.trim()) {
continue;
}
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, trimmed, {
await sendDiscordChunkWithFallback({
target: params.target,
text: chunk,
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo,
binding,
username: persona.username,
avatarUrl: persona.avatarUrl,
});
}
continue;
@@ -79,7 +184,7 @@ export async function deliverDiscordReply(params: {
continue;
}
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
if (payload.audioAsVoice) {
const replyTo = resolveReplyTo();
await sendVoiceMessageDiscord(params.target, firstMedia, {
@@ -88,17 +193,19 @@ export async function deliverDiscordReply(params: {
accountId: params.accountId,
replyTo,
});
// Voice messages cannot include text; send remaining text separately if present
if (text.trim()) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, text, {
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo,
});
}
// Additional media items are sent as regular attachments (voice is single-file only)
// Voice messages cannot include text; send remaining text separately if present.
await sendDiscordChunkWithFallback({
target: params.target,
text,
token: params.token,
rest: params.rest,
accountId: params.accountId,
replyTo: resolveReplyTo(),
binding,
username: persona.username,
avatarUrl: persona.avatarUrl,
});
// Additional media items are sent as regular attachments (voice is single-file only).
for (const extra of mediaList.slice(1)) {
const replyTo = resolveReplyTo();
await sendMessageDiscord(params.target, "", {