From 7da8cb410e2b12f02f01b635db27788a2fbbf456 Mon Sep 17 00:00:00 2001 From: Lalit Singh Date: Thu, 29 Jan 2026 10:29:07 +0100 Subject: [PATCH] feat(routing): add thread parent binding inheritance for Discord When a Discord thread message doesn't match a direct peer binding, now checks if the parent channel has a binding and uses that agent. This enables multi-agent setups where threads inherit their parent channel's agent binding automatically. Changes: - Add parentPeer parameter to ResolveAgentRouteInput - Add binding.peer.parent match type - Resolve thread parent early in Discord preflight - Pass parentPeer to resolveAgentRoute for threads Fixes thread routing in Discord multi-agent configurations where threads were incorrectly routed to the default agent instead of inheriting from their parent channel's binding. --- .../monitor/message-handler.preflight.ts | 56 ++++--- src/routing/resolve-route.test.ts | 157 ++++++++++++++++++ src/routing/resolve-route.ts | 12 ++ 3 files changed, 202 insertions(+), 23 deletions(-) diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 098533aed..893ebf4b9 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -166,6 +166,32 @@ export async function preflightDiscordMessage( accountId: params.accountId, direction: "inbound", }); + + // Resolve thread parent early for binding inheritance + const channelName = + channelInfo?.name ?? + ((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel + ? message.channel.name + : undefined); + const earlyThreadChannel = resolveDiscordThreadChannel({ + isGuildMessage, + message, + channelInfo, + }); + let earlyThreadParentId: string | undefined; + let earlyThreadParentName: string | undefined; + let earlyThreadParentType: ChannelType | undefined; + if (earlyThreadChannel) { + const parentInfo = await resolveDiscordThreadParentInfo({ + client: params.client, + threadChannel: earlyThreadChannel, + channelInfo, + }); + earlyThreadParentId = parentInfo.id; + earlyThreadParentName = parentInfo.name; + earlyThreadParentType = parentInfo.type; + } + const route = resolveAgentRoute({ cfg: params.cfg, channel: "discord", @@ -175,6 +201,8 @@ export async function preflightDiscordMessage( kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel", id: isDirectMessage ? author.id : message.channelId, }, + // Pass parent peer for thread binding inheritance + parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, }); const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId); const explicitlyMentioned = Boolean( @@ -236,29 +264,11 @@ export async function preflightDiscordMessage( return null; } - const channelName = - channelInfo?.name ?? - ((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel - ? message.channel.name - : undefined); - const threadChannel = resolveDiscordThreadChannel({ - isGuildMessage, - message, - channelInfo, - }); - let threadParentId: string | undefined; - let threadParentName: string | undefined; - let threadParentType: ChannelType | undefined; - if (threadChannel) { - const parentInfo = await resolveDiscordThreadParentInfo({ - client: params.client, - threadChannel, - channelInfo, - }); - threadParentId = parentInfo.id; - threadParentName = parentInfo.name; - threadParentType = parentInfo.type; - } + // Reuse early thread resolution from above (for binding inheritance) + const threadChannel = earlyThreadChannel; + const threadParentId = earlyThreadParentId; + const threadParentName = earlyThreadParentName; + const threadParentType = earlyThreadParentType; const threadName = threadChannel?.name; const configChannelName = threadParentName ?? channelName; const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : ""; diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index aed0fa755..948d8b170 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -253,3 +253,160 @@ test("dmScope=per-account-channel-peer uses default accountId when not provided" }); expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539"); }); + +describe("parentPeer binding inheritance (thread support)", () => { + test("thread inherits binding from parent channel when no direct match", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "adecco", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + }); + expect(route.agentId).toBe("adecco"); + expect(route.matchedBy).toBe("binding.peer.parent"); + }); + + test("direct peer binding wins over parent peer binding", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "thread-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + }, + }, + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + }); + expect(route.agentId).toBe("thread-agent"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("parent peer binding wins over guild binding", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + { + agentId: "guild-agent", + match: { + channel: "discord", + guildId: "guild-789", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + guildId: "guild-789", + }); + expect(route.agentId).toBe("parent-agent"); + expect(route.matchedBy).toBe("binding.peer.parent"); + }); + + test("falls back to guild binding when no parent peer match", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "other-parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "other-parent-999" }, + }, + }, + { + agentId: "guild-agent", + match: { + channel: "discord", + guildId: "guild-789", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "parent-channel-123" }, + guildId: "guild-789", + }); + expect(route.agentId).toBe("guild-agent"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("parentPeer with empty id is ignored", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: { kind: "channel", id: "" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); + + test("null parentPeer is handled gracefully", () => { + const cfg: MoltbotConfig = { + bindings: [ + { + agentId: "parent-agent", + match: { + channel: "discord", + peer: { kind: "channel", id: "parent-channel-123" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "thread-456" }, + parentPeer: null, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 0c63f77c8..d486762b3 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -22,6 +22,8 @@ export type ResolveAgentRouteInput = { channel: string; accountId?: string | null; peer?: RoutePeer | null; + /** Parent peer for threads — used for binding inheritance when peer doesn't match directly. */ + parentPeer?: RoutePeer | null; guildId?: string | null; teamId?: string | null; }; @@ -37,6 +39,7 @@ export type ResolvedAgentRoute = { /** Match description for debugging/logging. */ matchedBy: | "binding.peer" + | "binding.peer.parent" | "binding.guild" | "binding.team" | "binding.account" @@ -186,6 +189,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (peerMatch) return choose(peerMatch.agentId, "binding.peer"); } + // Thread parent inheritance: if peer (thread) didn't match, check parent peer binding + const parentPeer = input.parentPeer + ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } + : null; + if (parentPeer && parentPeer.id) { + const parentPeerMatch = bindings.find((b) => matchesPeer(b.match, parentPeer)); + if (parentPeerMatch) return choose(parentPeerMatch.agentId, "binding.peer.parent"); + } + if (guildId) { const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId)); if (guildMatch) return choose(guildMatch.agentId, "binding.guild");