routing: support wildcard peer bindings (peer.id="*") for multi-agent routing (#58609)

* routing: support wildcard peer bindings (peer.id="*") for multi-agent routing

Bindings with `peer: { kind: "direct", id: "*" }` were treated as a literal
peer ID "*" instead of a wildcard. This caused the binding to be indexed
exclusively in the byPeer map under key "direct:*", which never matches
actual incoming peer IDs like "direct:12345678". The binding silently fell
through to the default agent ("main"), breaking multi-agent setups that use
wildcard peer constraints to route all DMs on a named account to a specific
agent.

Add a "wildcard-kind" peer constraint state that restricts on chat type
(direct/group/channel) without requiring an exact peer ID match. Wildcard
peer bindings now fall through to the byAccount/byChannel index tiers and
correctly match via matchesBindingScope with kind-only filtering.

Resolves #58546

Made-with: Cursor

* routing: add dedicated binding.peer.wildcard tier for clarity

Address Greptile feedback: wildcard-peer bindings now report
matchedBy: "binding.peer.wildcard" instead of "binding.account",
making logs/debugging clearer for operators.

- Add byPeerWildcard index bucket
- Add binding.peer.wildcard tier between peer.parent and guild+roles
- Update tests to expect the new matchedBy value

Made-with: Cursor
This commit is contained in:
OfflynAI
2026-03-31 20:10:18 -05:00
committed by GitHub
parent b86f5d5ea4
commit b554516f21
2 changed files with 166 additions and 0 deletions

View File

@@ -922,6 +922,145 @@ describe("role-based agent routing", () => {
});
});
describe("wildcard peer bindings (peer.id=*)", () => {
test("peer.id=* matches any direct peer and routes to the bound agent", () => {
const cfg: OpenClawConfig = {
agents: { list: [{ id: "second-ana" }] },
bindings: [
{
agentId: "second-ana",
match: {
channel: "telegram",
accountId: "second-ana",
peer: { kind: "direct", id: "*" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: "second-ana",
peer: { kind: "direct", id: "12345678" },
});
expect(route.agentId).toBe("second-ana");
expect(route.sessionKey).toContain("agent:second-ana:");
expect(route.matchedBy).toBe("binding.peer.wildcard");
});
test("peer.id=* does not match group peers when kind is direct", () => {
const cfg: OpenClawConfig = {
agents: { list: [{ id: "main", default: true }, { id: "dm-only" }] },
bindings: [
{
agentId: "dm-only",
match: {
channel: "telegram",
accountId: "bot1",
peer: { kind: "direct", id: "*" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "telegram",
accountId: "bot1",
peer: { kind: "group", id: "group-999" },
});
expect(route.agentId).toBe("main");
expect(route.matchedBy).toBe("default");
});
test("exact peer binding wins over wildcard peer binding", () => {
const cfg: OpenClawConfig = {
agents: { list: [{ id: "exact" }, { id: "wild" }] },
bindings: [
{
agentId: "wild",
match: {
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "*" },
},
},
{
agentId: "exact",
match: {
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "+1000" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "+1000" },
});
expect(route.agentId).toBe("exact");
expect(route.matchedBy).toBe("binding.peer");
});
test("wildcard peer binding wins over default fallback for unmatched peers", () => {
const cfg: OpenClawConfig = {
agents: { list: [{ id: "exact" }, { id: "wild" }] },
bindings: [
{
agentId: "wild",
match: {
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "*" },
},
},
{
agentId: "exact",
match: {
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "+1000" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "+9999" },
});
expect(route.agentId).toBe("wild");
expect(route.matchedBy).toBe("binding.peer.wildcard");
});
test("group wildcard peer matches any group peer", () => {
const cfg: OpenClawConfig = {
agents: { list: [{ id: "grp" }] },
bindings: [
{
agentId: "grp",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "group", id: "*" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId: "default",
peer: { kind: "group", id: "g-42" },
});
expect(route.agentId).toBe("grp");
expect(route.matchedBy).toBe("binding.peer.wildcard");
});
});
describe("binding evaluation cache scalability", () => {
test("does not rescan full bindings after channel/account cache rollover (#36915)", () => {
const bindingCount = 2_205;

View File

@@ -50,6 +50,7 @@ export type ResolvedAgentRoute = {
matchedBy:
| "binding.peer"
| "binding.peer.parent"
| "binding.peer.wildcard"
| "binding.guild+roles"
| "binding.guild"
| "binding.team"
@@ -168,6 +169,7 @@ export function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string):
type NormalizedPeerConstraint =
| { state: "none" }
| { state: "invalid" }
| { state: "wildcard-kind"; kind: ChatType }
| { state: "valid"; kind: ChatType; id: string };
type NormalizedBindingMatch = {
@@ -213,6 +215,7 @@ const MAX_RESOLVED_ROUTE_CACHE_KEYS = 4000;
type EvaluatedBindingsIndex = {
byPeer: Map<string, EvaluatedBinding[]>;
byPeerWildcard: EvaluatedBinding[];
byGuildWithRoles: Map<string, EvaluatedBinding[]>;
byGuild: Map<string, EvaluatedBinding[]>;
byTeam: Map<string, EvaluatedBinding[]>;
@@ -368,6 +371,7 @@ function collectPeerIndexedBindings(
function buildEvaluatedBindingsIndex(bindings: EvaluatedBinding[]): EvaluatedBindingsIndex {
const byPeer = new Map<string, EvaluatedBinding[]>();
const byPeerWildcard: EvaluatedBinding[] = [];
const byGuildWithRoles = new Map<string, EvaluatedBinding[]>();
const byGuild = new Map<string, EvaluatedBinding[]>();
const byTeam = new Map<string, EvaluatedBinding[]>();
@@ -381,6 +385,10 @@ function buildEvaluatedBindingsIndex(bindings: EvaluatedBinding[]): EvaluatedBin
}
continue;
}
if (binding.match.peer.state === "wildcard-kind") {
byPeerWildcard.push(binding);
continue;
}
if (binding.match.guildId && binding.match.roles) {
pushToIndexMap(byGuildWithRoles, binding.match.guildId, binding);
continue;
@@ -402,6 +410,7 @@ function buildEvaluatedBindingsIndex(bindings: EvaluatedBinding[]): EvaluatedBin
return {
byPeer,
byPeerWildcard,
byGuildWithRoles,
byGuild,
byTeam,
@@ -481,6 +490,9 @@ function normalizePeerConstraint(
if (!kind || !id) {
return { state: "invalid" };
}
if (id === "*") {
return { state: "wildcard-kind", kind };
}
return { state: "valid", kind, id };
}
@@ -594,6 +606,11 @@ function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope)
return false;
}
}
if (match.peer.state === "wildcard-kind") {
if (!scope.peer || !peerKindMatches(match.peer.kind, scope.peer.kind)) {
return false;
}
}
if (match.guildId && match.guildId !== scope.guildId) {
return false;
}
@@ -700,6 +717,9 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
if (value.state === "invalid") {
return "invalid";
}
if (value.state === "wildcard-kind") {
return `${value.kind}:*`;
}
return `${value.kind}:${value.id}`;
};
@@ -741,6 +761,13 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
candidates: collectPeerIndexedBindings(bindingsIndex, parentPeer),
predicate: (candidate) => candidate.match.peer.state === "valid",
},
{
matchedBy: "binding.peer.wildcard",
enabled: Boolean(peer),
scopePeer: peer,
candidates: bindingsIndex.byPeerWildcard,
predicate: (candidate) => candidate.match.peer.state === "wildcard-kind",
},
{
matchedBy: "binding.guild+roles",
enabled: Boolean(guildId && memberRoleIds.length > 0),