diff --git a/CHANGELOG.md b/CHANGELOG.md index 456c56c3f07..218dd90ffab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second `resolveAgentRoute` stalls in large binding configurations. (#36915) Thanks @songchenghao. - Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during `sessions.reset`/`sessions.delete` runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693. - Slack/local file upload allowlist parity: propagate `mediaLocalRoots` through the Slack send action pipeline so workspace-rooted attachments pass `assertLocalMediaAllowed` checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin. - Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin. diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 5d23303e3ca..00bc55c350c 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import type { ChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/config.js"; +import * as routingBindings from "./bindings.js"; import { resolveAgentRoute } from "./resolve-route.js"; describe("resolveAgentRoute", () => { @@ -768,3 +769,43 @@ describe("role-based agent routing", () => { }); }); }); + +describe("binding evaluation cache scalability", () => { + test("does not rescan full bindings after channel/account cache rollover (#36915)", () => { + const bindingCount = 2_205; + const cfg: OpenClawConfig = { + bindings: Array.from({ length: bindingCount }, (_, idx) => ({ + agentId: `agent-${idx}`, + match: { + channel: "dingtalk", + accountId: `acct-${idx}`, + peer: { kind: "direct", id: `user-${idx}` }, + }, + })), + }; + const listBindingsSpy = vi.spyOn(routingBindings, "listBindings"); + try { + for (let idx = 0; idx < bindingCount; idx += 1) { + const route = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: `acct-${idx}`, + peer: { kind: "direct", id: `user-${idx}` }, + }); + expect(route.agentId).toBe(`agent-${idx}`); + expect(route.matchedBy).toBe("binding.peer"); + } + + const repeated = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-0", + peer: { kind: "direct", id: "user-0" }, + }); + expect(repeated.agentId).toBe("agent-0"); + expect(listBindingsSpy).toHaveBeenCalledTimes(1); + } finally { + listBindingsSpy.mockRestore(); + } + }); +}); diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index b2310e20ae8..29a7d9c1152 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -72,17 +72,6 @@ function normalizeId(value: unknown): string { return ""; } -function matchesAccountId(match: string | undefined, actual: string): boolean { - const trimmed = (match ?? "").trim(); - if (!trimmed) { - return actual === DEFAULT_ACCOUNT_ID; - } - if (trimmed === "*") { - return true; - } - return normalizeAccountId(trimmed) === actual; -} - export function buildAgentSessionKey(params: { agentId: string; channel: string; @@ -160,17 +149,6 @@ export function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): return lookup.fallbackDefaultAgentId; } -function matchesChannel( - match: { channel?: string | undefined } | undefined, - channel: string, -): boolean { - const key = normalizeToken(match?.channel); - if (!key) { - return false; - } - return key === channel; -} - type NormalizedPeerConstraint = | { state: "none" } | { state: "invalid" } @@ -187,6 +165,7 @@ type NormalizedBindingMatch = { type EvaluatedBinding = { binding: ReturnType[number]; match: NormalizedBindingMatch; + order: number; }; type BindingScope = { @@ -198,6 +177,7 @@ type BindingScope = { type EvaluatedBindingsCache = { bindingsRef: OpenClawConfig["bindings"]; + byChannel: Map; byChannelAccount: Map; byChannelAccountIndex: Map; }; @@ -224,6 +204,101 @@ type EvaluatedBindingsIndex = { byChannel: EvaluatedBinding[]; }; +type EvaluatedBindingsByChannel = { + byAccount: Map; + byAnyAccount: EvaluatedBinding[]; +}; + +function resolveAccountPatternKey(accountPattern: string): string { + if (!accountPattern.trim()) { + return DEFAULT_ACCOUNT_ID; + } + return normalizeAccountId(accountPattern); +} + +function buildEvaluatedBindingsByChannel( + cfg: OpenClawConfig, +): Map { + const byChannel = new Map(); + let order = 0; + for (const binding of listBindings(cfg)) { + if (!binding || typeof binding !== "object") { + continue; + } + const channel = normalizeToken(binding.match?.channel); + if (!channel) { + continue; + } + const match = normalizeBindingMatch(binding.match); + const evaluated: EvaluatedBinding = { + binding, + match, + order, + }; + order += 1; + let bucket = byChannel.get(channel); + if (!bucket) { + bucket = { + byAccount: new Map(), + byAnyAccount: [], + }; + byChannel.set(channel, bucket); + } + if (match.accountPattern === "*") { + bucket.byAnyAccount.push(evaluated); + continue; + } + const accountKey = resolveAccountPatternKey(match.accountPattern); + const existing = bucket.byAccount.get(accountKey); + if (existing) { + existing.push(evaluated); + continue; + } + bucket.byAccount.set(accountKey, [evaluated]); + } + return byChannel; +} + +function mergeEvaluatedBindingsInSourceOrder( + accountScoped: EvaluatedBinding[], + anyAccount: EvaluatedBinding[], +): EvaluatedBinding[] { + if (accountScoped.length === 0) { + return anyAccount; + } + if (anyAccount.length === 0) { + return accountScoped; + } + const merged: EvaluatedBinding[] = []; + let accountIdx = 0; + let anyIdx = 0; + while (accountIdx < accountScoped.length && anyIdx < anyAccount.length) { + const accountBinding = accountScoped[accountIdx]; + const anyBinding = anyAccount[anyIdx]; + if ( + (accountBinding?.order ?? Number.MAX_SAFE_INTEGER) <= + (anyBinding?.order ?? Number.MAX_SAFE_INTEGER) + ) { + if (accountBinding) { + merged.push(accountBinding); + } + accountIdx += 1; + continue; + } + if (anyBinding) { + merged.push(anyBinding); + } + anyIdx += 1; + } + if (accountIdx < accountScoped.length) { + merged.push(...accountScoped.slice(accountIdx)); + } + if (anyIdx < anyAccount.length) { + merged.push(...anyAccount.slice(anyIdx)); + } + return merged; +} + function pushToIndexMap( map: Map, key: string | null, @@ -331,6 +406,7 @@ function getEvaluatedBindingsForChannelAccount( ? existing : { bindingsRef, + byChannel: buildEvaluatedBindingsByChannel(cfg), byChannelAccount: new Map(), byChannelAccountIndex: new Map(), }; @@ -344,18 +420,10 @@ function getEvaluatedBindingsForChannelAccount( return hit; } - const evaluated: EvaluatedBinding[] = listBindings(cfg).flatMap((binding) => { - if (!binding || typeof binding !== "object") { - return []; - } - if (!matchesChannel(binding.match, channel)) { - return []; - } - if (!matchesAccountId(binding.match?.accountId, accountId)) { - return []; - } - return [{ binding, match: normalizeBindingMatch(binding.match) }]; - }); + const channelBindings = cache.byChannel.get(channel); + const accountScoped = channelBindings?.byAccount.get(accountId) ?? []; + const anyAccount = channelBindings?.byAnyAccount ?? []; + const evaluated = mergeEvaluatedBindingsInSourceOrder(accountScoped, anyAccount); cache.byChannelAccount.set(cacheKey, evaluated); cache.byChannelAccountIndex.set(cacheKey, buildEvaluatedBindingsIndex(evaluated));