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.
This commit is contained in:
Lalit Singh
2026-01-29 10:29:07 +01:00
parent 6372242da7
commit 7da8cb410e
3 changed files with 202 additions and 23 deletions

View File

@@ -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) : "";

View File

@@ -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");
});
});

View File

@@ -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");