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:
parent
6372242da7
commit
7da8cb410e
@ -166,6 +166,32 @@ export async function preflightDiscordMessage(
|
|||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
direction: "inbound",
|
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({
|
const route = resolveAgentRoute({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@ -175,6 +201,8 @@ export async function preflightDiscordMessage(
|
|||||||
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
|
||||||
id: isDirectMessage ? author.id : message.channelId,
|
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 mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
|
||||||
const explicitlyMentioned = Boolean(
|
const explicitlyMentioned = Boolean(
|
||||||
@ -236,29 +264,11 @@ export async function preflightDiscordMessage(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelName =
|
// Reuse early thread resolution from above (for binding inheritance)
|
||||||
channelInfo?.name ??
|
const threadChannel = earlyThreadChannel;
|
||||||
((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel
|
const threadParentId = earlyThreadParentId;
|
||||||
? message.channel.name
|
const threadParentName = earlyThreadParentName;
|
||||||
: undefined);
|
const threadParentType = earlyThreadParentType;
|
||||||
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;
|
|
||||||
}
|
|
||||||
const threadName = threadChannel?.name;
|
const threadName = threadChannel?.name;
|
||||||
const configChannelName = threadParentName ?? channelName;
|
const configChannelName = threadParentName ?? channelName;
|
||||||
const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : "";
|
const configChannelSlug = configChannelName ? normalizeDiscordSlug(configChannelName) : "";
|
||||||
|
|||||||
@ -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");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -22,6 +22,8 @@ export type ResolveAgentRouteInput = {
|
|||||||
channel: string;
|
channel: string;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
peer?: RoutePeer | null;
|
peer?: RoutePeer | null;
|
||||||
|
/** Parent peer for threads — used for binding inheritance when peer doesn't match directly. */
|
||||||
|
parentPeer?: RoutePeer | null;
|
||||||
guildId?: string | null;
|
guildId?: string | null;
|
||||||
teamId?: string | null;
|
teamId?: string | null;
|
||||||
};
|
};
|
||||||
@ -37,6 +39,7 @@ export type ResolvedAgentRoute = {
|
|||||||
/** Match description for debugging/logging. */
|
/** Match description for debugging/logging. */
|
||||||
matchedBy:
|
matchedBy:
|
||||||
| "binding.peer"
|
| "binding.peer"
|
||||||
|
| "binding.peer.parent"
|
||||||
| "binding.guild"
|
| "binding.guild"
|
||||||
| "binding.team"
|
| "binding.team"
|
||||||
| "binding.account"
|
| "binding.account"
|
||||||
@ -186,6 +189,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
|||||||
if (peerMatch) return choose(peerMatch.agentId, "binding.peer");
|
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) {
|
if (guildId) {
|
||||||
const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId));
|
const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId));
|
||||||
if (guildMatch) return choose(guildMatch.agentId, "binding.guild");
|
if (guildMatch) return choose(guildMatch.agentId, "binding.guild");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user