mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 05:10:23 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user