diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0aa6c56b5..5cbd1517745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai - Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293. - Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic. - Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo. +- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo. - Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo. - Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 6ee19453917..49c4a6120d6 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -44,11 +44,15 @@ Examples: Routing picks **one agent** for each inbound message: 1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`). -2. **Guild match** (Discord) via `guildId`. -3. **Team match** (Slack) via `teamId`. -4. **Account match** (`accountId` on the channel). -5. **Channel match** (any account on that channel). -6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). +2. **Parent peer match** (thread inheritance). +3. **Guild + roles match** (Discord) via `guildId` + `roles`. +4. **Guild match** (Discord) via `guildId`. +5. **Team match** (Slack) via `teamId`. +6. **Account match** (`accountId` on the channel). +7. **Channel match** (any account on that channel, `accountId: "*"`). +8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). + +When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply. The matched agent determines which workspace and session store are used. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 3f3031fa337..06f8ddf76ab 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -173,7 +173,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D ### Role-based agent routing -Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. +Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match. ```json5 { diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 027654a9006..8f4c05a7cc8 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -125,11 +125,15 @@ Notes: Bindings are **deterministic** and **most-specific wins**: 1. `peer` match (exact DM/group/channel id) -2. `guildId` (Discord) -3. `teamId` (Slack) -4. `accountId` match for a channel -5. channel-level match (`accountId: "*"`) -6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`) +2. `parentPeer` match (thread inheritance) +3. `guildId + roles` (Discord role routing) +4. `guildId` (Discord) +5. `teamId` (Slack) +6. `accountId` match for a channel +7. channel-level match (`accountId: "*"`) +8. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`) + +If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics). ## Multiple accounts / phone numbers diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 412e002ffdf..5c45eb69c3c 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -169,6 +169,126 @@ describe("resolveAgentRoute", () => { expect(route.matchedBy).toBe("binding.guild"); }); + test("peer+guild binding does not act as guild-wide fallback when peer mismatches (#14752)", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "olga", + match: { + channel: "discord", + peer: { kind: "channel", id: "CHANNEL_A" }, + guildId: "GUILD_1", + }, + }, + { + agentId: "main", + match: { + channel: "discord", + guildId: "GUILD_1", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "CHANNEL_B" }, + guildId: "GUILD_1", + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("peer+guild binding requires guild match even when peer matches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "wrongguild", + match: { + channel: "discord", + peer: { kind: "channel", id: "c1" }, + guildId: "g1", + }, + }, + { + agentId: "rightguild", + match: { + channel: "discord", + guildId: "g2", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + peer: { kind: "channel", id: "c1" }, + guildId: "g2", + }); + expect(route.agentId).toBe("rightguild"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("peer+team binding does not act as team-wide fallback when peer mismatches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "roomonly", + match: { + channel: "slack", + peer: { kind: "channel", id: "C_A" }, + teamId: "T1", + }, + }, + { + agentId: "teamwide", + match: { + channel: "slack", + teamId: "T1", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + teamId: "T1", + peer: { kind: "channel", id: "C_B" }, + }); + expect(route.agentId).toBe("teamwide"); + expect(route.matchedBy).toBe("binding.team"); + }); + + test("peer+team binding requires team match even when peer matches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "wrongteam", + match: { + channel: "slack", + peer: { kind: "channel", id: "C1" }, + teamId: "T1", + }, + }, + { + agentId: "rightteam", + match: { + channel: "slack", + teamId: "T2", + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + teamId: "T2", + peer: { kind: "channel", id: "C1" }, + }); + expect(route.agentId).toBe("rightteam"); + expect(route.matchedBy).toBe("binding.team"); + }); + test("missing accountId in binding matches default account only", () => { const cfg: OpenClawConfig = { bindings: [{ agentId: "defaultAcct", match: { channel: "whatsapp" } }], @@ -592,4 +712,37 @@ describe("role-based agent routing", () => { expect(route.agentId).toBe("main"); expect(route.matchedBy).toBe("default"); }); + + test("peer+guild+roles binding does not act as guild+roles fallback when peer mismatches", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "peer-roles", + match: { + channel: "discord", + peer: { kind: "channel", id: "c-target" }, + guildId: "g1", + roles: ["r1"], + }, + }, + { + agentId: "guild-roles", + match: { + channel: "discord", + guildId: "g1", + roles: ["r1"], + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1"], + peer: { kind: "channel", id: "c-other" }, + }); + expect(route.agentId).toBe("guild-roles"); + expect(route.matchedBy).toBe("binding.guild+roles"); + }); }); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 55c7d5e475e..e59f53721c6 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -152,25 +152,6 @@ function matchesPeer( return kind === peer.kind && id === peer.id; } -function matchesGuild( - match: { guildId?: string | undefined } | undefined, - guildId: string, -): boolean { - const id = normalizeId(match?.guildId); - if (!id) { - return false; - } - return id === guildId; -} - -function matchesTeam(match: { teamId?: string | undefined } | undefined, teamId: string): boolean { - const id = normalizeId(match?.teamId); - if (!id) { - return false; - } - return id === teamId; -} - function matchesRoles( match: { roles?: string[] | undefined } | undefined, memberRoleIds: string[], @@ -182,6 +163,91 @@ function matchesRoles( return roles.some((role) => memberRoleIds.includes(role)); } +function hasGuildConstraint(match: { guildId?: string | undefined } | undefined): boolean { + return Boolean(normalizeId(match?.guildId)); +} + +function hasTeamConstraint(match: { teamId?: string | undefined } | undefined): boolean { + return Boolean(normalizeId(match?.teamId)); +} + +function hasRolesConstraint(match: { roles?: string[] | undefined } | undefined): boolean { + return Array.isArray(match?.roles) && match.roles.length > 0; +} + +function matchesOptionalPeer( + match: { peer?: { kind?: string; id?: string } | undefined } | undefined, + peer: RoutePeer | null, +): boolean { + if (!match?.peer) { + return true; + } + if (!peer) { + return false; + } + return matchesPeer(match, peer); +} + +function matchesOptionalGuild( + match: { guildId?: string | undefined } | undefined, + guildId: string, +): boolean { + const requiredGuildId = normalizeId(match?.guildId); + if (!requiredGuildId) { + return true; + } + if (!guildId) { + return false; + } + return requiredGuildId === guildId; +} + +function matchesOptionalTeam( + match: { teamId?: string | undefined } | undefined, + teamId: string, +): boolean { + const requiredTeamId = normalizeId(match?.teamId); + if (!requiredTeamId) { + return true; + } + if (!teamId) { + return false; + } + return requiredTeamId === teamId; +} + +function matchesOptionalRoles( + match: { roles?: string[] | undefined } | undefined, + memberRoleIds: string[], +): boolean { + if (!hasRolesConstraint(match)) { + return true; + } + return matchesRoles(match, memberRoleIds); +} + +function matchesBindingScope(params: { + match: + | { + peer?: { kind?: string; id?: string } | undefined; + guildId?: string | undefined; + teamId?: string | undefined; + roles?: string[] | undefined; + } + | undefined; + peer: RoutePeer | null; + guildId: string; + teamId: string; + memberRoleIds: string[]; +}): boolean { + return ( + matchesOptionalPeer(params.match, params.peer) && + matchesOptionalGuild(params.match, params.guildId) && + matchesOptionalTeam(params.match, params.teamId) && + matchesOptionalRoles(params.match, params.memberRoleIds) + ); +} + export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute { const channel = normalizeToken(input.channel); const accountId = normalizeAccountId(input.accountId); @@ -228,7 +294,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR }; if (peer) { - const peerMatch = bindings.find((b) => matchesPeer(b.match, peer)); + const peerMatch = bindings.find( + (b) => + Boolean(b.match?.peer) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), + ); if (peerMatch) { return choose(peerMatch.agentId, "binding.peer"); } @@ -239,7 +315,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR ? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) } : null; if (parentPeer && parentPeer.id) { - const parentPeerMatch = bindings.find((b) => matchesPeer(b.match, parentPeer)); + const parentPeerMatch = bindings.find( + (b) => + Boolean(b.match?.peer) && + matchesBindingScope({ + match: b.match, + peer: parentPeer, + guildId, + teamId, + memberRoleIds, + }), + ); if (parentPeerMatch) { return choose(parentPeerMatch.agentId, "binding.peer.parent"); } @@ -247,7 +333,16 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (guildId && memberRoleIds.length > 0) { const guildRolesMatch = bindings.find( - (b) => matchesGuild(b.match, guildId) && matchesRoles(b.match, memberRoleIds), + (b) => + hasGuildConstraint(b.match) && + hasRolesConstraint(b.match) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (guildRolesMatch) { return choose(guildRolesMatch.agentId, "binding.guild+roles"); @@ -257,8 +352,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (guildId) { const guildMatch = bindings.find( (b) => - matchesGuild(b.match, guildId) && - (!Array.isArray(b.match?.roles) || b.match.roles.length === 0), + hasGuildConstraint(b.match) && + !hasRolesConstraint(b.match) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (guildMatch) { return choose(guildMatch.agentId, "binding.guild"); @@ -266,7 +368,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR } if (teamId) { - const teamMatch = bindings.find((b) => matchesTeam(b.match, teamId)); + const teamMatch = bindings.find( + (b) => + hasTeamConstraint(b.match) && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), + ); if (teamMatch) { return choose(teamMatch.agentId, "binding.team"); } @@ -274,7 +386,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const accountMatch = bindings.find( (b) => - b.match?.accountId?.trim() !== "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId, + b.match?.accountId?.trim() !== "*" && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (accountMatch) { return choose(accountMatch.agentId, "binding.account"); @@ -282,7 +401,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const anyAccountMatch = bindings.find( (b) => - b.match?.accountId?.trim() === "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId, + b.match?.accountId?.trim() === "*" && + matchesBindingScope({ + match: b.match, + peer, + guildId, + teamId, + memberRoleIds, + }), ); if (anyAccountMatch) { return choose(anyAccountMatch.agentId, "binding.channel");