import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ChatType } from "../channels/chat-type.js"; import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/config.js"; import { shouldLogVerbose } from "../globals.js"; import { logDebug } from "../logger.js"; import { listBindings } from "./bindings.js"; import { buildAgentMainSessionKey, buildAgentPeerSessionKey, DEFAULT_ACCOUNT_ID, DEFAULT_MAIN_KEY, normalizeAccountId, normalizeAgentId, sanitizeAgentId, } from "./session-key.js"; /** @deprecated Use ChatType from channels/chat-type.js */ export type RoutePeerKind = ChatType; export type RoutePeer = { kind: ChatType; id: string; }; export type ResolveAgentRouteInput = { cfg: OpenClawConfig; channel: string; accountId?: string | null; peer?: RoutePeer | null; /** Parent peer for threads — used for binding inheritance when peer doesn't match directly. */ parentPeer?: RoutePeer | null; guildId?: string | null; teamId?: string | null; /** Discord member role IDs — used for role-based agent routing. */ memberRoleIds?: string[]; }; export type ResolvedAgentRoute = { agentId: string; channel: string; accountId: string; /** Internal session key used for persistence + concurrency. */ sessionKey: string; /** Convenience alias for direct-chat collapse. */ mainSessionKey: string; /** Match description for debugging/logging. */ matchedBy: | "binding.peer" | "binding.peer.parent" | "binding.guild+roles" | "binding.guild" | "binding.team" | "binding.account" | "binding.channel" | "default"; }; export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js"; function normalizeToken(value: string | undefined | null): string { return (value ?? "").trim().toLowerCase(); } function normalizeId(value: unknown): string { if (typeof value === "string") { return value.trim(); } if (typeof value === "number" || typeof value === "bigint") { return String(value).trim(); } 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; accountId?: string | null; peer?: RoutePeer | null; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; identityLinks?: Record; }): string { const channel = normalizeToken(params.channel) || "unknown"; const peer = params.peer; return buildAgentPeerSessionKey({ agentId: params.agentId, mainKey: DEFAULT_MAIN_KEY, channel, accountId: params.accountId, peerKind: peer?.kind ?? "direct", peerId: peer ? normalizeId(peer.id) || "unknown" : null, dmScope: params.dmScope, identityLinks: params.identityLinks, }); } function listAgents(cfg: OpenClawConfig) { const agents = cfg.agents?.list; return Array.isArray(agents) ? agents : []; } function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): string { const trimmed = (agentId ?? "").trim(); if (!trimmed) { return sanitizeAgentId(resolveDefaultAgentId(cfg)); } const normalized = normalizeAgentId(trimmed); const agents = listAgents(cfg); if (agents.length === 0) { return sanitizeAgentId(trimmed); } const match = agents.find((agent) => normalizeAgentId(agent.id) === normalized); if (match?.id?.trim()) { return sanitizeAgentId(match.id.trim()); } return sanitizeAgentId(resolveDefaultAgentId(cfg)); } 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" } | { state: "valid"; kind: ChatType; id: string }; type NormalizedBindingMatch = { accountPattern: string; peer: NormalizedPeerConstraint; guildId: string | null; teamId: string | null; roles: string[] | null; }; type EvaluatedBinding = { binding: ReturnType[number]; match: NormalizedBindingMatch; }; type BindingScope = { peer: RoutePeer | null; guildId: string; teamId: string; memberRoleIds: Set; }; type EvaluatedBindingsCache = { bindingsRef: OpenClawConfig["bindings"]; byChannelAccount: Map; }; const evaluatedBindingsCacheByCfg = new WeakMap(); const MAX_EVALUATED_BINDINGS_CACHE_KEYS = 2000; function getEvaluatedBindingsForChannelAccount( cfg: OpenClawConfig, channel: string, accountId: string, ): EvaluatedBinding[] { const bindingsRef = cfg.bindings; const existing = evaluatedBindingsCacheByCfg.get(cfg); const cache = existing && existing.bindingsRef === bindingsRef ? existing : { bindingsRef, byChannelAccount: new Map() }; if (cache !== existing) { evaluatedBindingsCacheByCfg.set(cfg, cache); } const cacheKey = `${channel}\t${accountId}`; const hit = cache.byChannelAccount.get(cacheKey); if (hit) { 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) }]; }); cache.byChannelAccount.set(cacheKey, evaluated); if (cache.byChannelAccount.size > MAX_EVALUATED_BINDINGS_CACHE_KEYS) { cache.byChannelAccount.clear(); cache.byChannelAccount.set(cacheKey, evaluated); } return evaluated; } function normalizePeerConstraint( peer: { kind?: string; id?: string } | undefined, ): NormalizedPeerConstraint { if (!peer) { return { state: "none" }; } const kind = normalizeChatType(peer.kind); const id = normalizeId(peer.id); if (!kind || !id) { return { state: "invalid" }; } return { state: "valid", kind, id }; } function normalizeBindingMatch( match: | { accountId?: string | undefined; peer?: { kind?: string; id?: string } | undefined; guildId?: string | undefined; teamId?: string | undefined; roles?: string[] | undefined; } | undefined, ): NormalizedBindingMatch { const rawRoles = match?.roles; return { accountPattern: (match?.accountId ?? "").trim(), peer: normalizePeerConstraint(match?.peer), guildId: normalizeId(match?.guildId) || null, teamId: normalizeId(match?.teamId) || null, roles: Array.isArray(rawRoles) && rawRoles.length > 0 ? rawRoles : null, }; } function hasGuildConstraint(match: NormalizedBindingMatch): boolean { return Boolean(match.guildId); } function hasTeamConstraint(match: NormalizedBindingMatch): boolean { return Boolean(match.teamId); } function hasRolesConstraint(match: NormalizedBindingMatch): boolean { return Boolean(match.roles); } function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope): boolean { if (match.peer.state === "invalid") { return false; } if (match.peer.state === "valid") { if (!scope.peer || scope.peer.kind !== match.peer.kind || scope.peer.id !== match.peer.id) { return false; } } if (match.guildId && match.guildId !== scope.guildId) { return false; } if (match.teamId && match.teamId !== scope.teamId) { return false; } if (match.roles) { for (const role of match.roles) { if (scope.memberRoleIds.has(role)) { return true; } } return false; } return true; } export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute { const channel = normalizeToken(input.channel); const accountId = normalizeAccountId(input.accountId); const peer = input.peer ? { kind: input.peer.kind, id: normalizeId(input.peer.id) } : null; const guildId = normalizeId(input.guildId); const teamId = normalizeId(input.teamId); const memberRoleIds = input.memberRoleIds ?? []; const memberRoleIdSet = new Set(memberRoleIds); const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId); const dmScope = input.cfg.session?.dmScope ?? "main"; const identityLinks = input.cfg.session?.identityLinks; const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => { const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); const sessionKey = buildAgentSessionKey({ agentId: resolvedAgentId, channel, accountId, peer, dmScope, identityLinks, }).toLowerCase(); const mainSessionKey = buildAgentMainSessionKey({ agentId: resolvedAgentId, mainKey: DEFAULT_MAIN_KEY, }).toLowerCase(); return { agentId: resolvedAgentId, channel, accountId, sessionKey, mainSessionKey, matchedBy, }; }; const shouldLogDebug = shouldLogVerbose(); const formatPeer = (value?: RoutePeer | null) => value?.kind && value?.id ? `${value.kind}:${value.id}` : "none"; const formatNormalizedPeer = (value: NormalizedPeerConstraint) => { if (value.state === "none") { return "none"; } if (value.state === "invalid") { return "invalid"; } return `${value.kind}:${value.id}`; }; if (shouldLogDebug) { logDebug( `[routing] resolveAgentRoute: channel=${channel} accountId=${accountId} peer=${formatPeer(peer)} guildId=${guildId || "none"} teamId=${teamId || "none"} bindings=${bindings.length}`, ); for (const entry of bindings) { logDebug( `[routing] binding: agentId=${entry.binding.agentId} accountPattern=${entry.match.accountPattern || "default"} peer=${formatNormalizedPeer(entry.match.peer)} guildId=${entry.match.guildId ?? "none"} teamId=${entry.match.teamId ?? "none"} roles=${entry.match.roles?.length ?? 0}`, ); } } // 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; const baseScope = { guildId, teamId, memberRoleIds: memberRoleIdSet, }; const tiers: Array<{ matchedBy: Exclude; enabled: boolean; scopePeer: RoutePeer | null; predicate: (candidate: EvaluatedBinding) => boolean; }> = [ { matchedBy: "binding.peer", enabled: Boolean(peer), scopePeer: peer, predicate: (candidate) => candidate.match.peer.state === "valid", }, { matchedBy: "binding.peer.parent", enabled: Boolean(parentPeer && parentPeer.id), scopePeer: parentPeer && parentPeer.id ? parentPeer : null, predicate: (candidate) => candidate.match.peer.state === "valid", }, { matchedBy: "binding.guild+roles", enabled: Boolean(guildId && memberRoleIds.length > 0), scopePeer: peer, predicate: (candidate) => hasGuildConstraint(candidate.match) && hasRolesConstraint(candidate.match), }, { matchedBy: "binding.guild", enabled: Boolean(guildId), scopePeer: peer, predicate: (candidate) => hasGuildConstraint(candidate.match) && !hasRolesConstraint(candidate.match), }, { matchedBy: "binding.team", enabled: Boolean(teamId), scopePeer: peer, predicate: (candidate) => hasTeamConstraint(candidate.match), }, { matchedBy: "binding.account", enabled: true, scopePeer: peer, predicate: (candidate) => candidate.match.accountPattern !== "*", }, { matchedBy: "binding.channel", enabled: true, scopePeer: peer, predicate: (candidate) => candidate.match.accountPattern === "*", }, ]; for (const tier of tiers) { if (!tier.enabled) { continue; } const matched = bindings.find( (candidate) => tier.predicate(candidate) && matchesBindingScope(candidate.match, { ...baseScope, peer: tier.scopePeer, }), ); if (matched) { if (shouldLogDebug) { logDebug(`[routing] match: matchedBy=${tier.matchedBy} agentId=${matched.binding.agentId}`); } return choose(matched.binding.agentId, tier.matchedBy); } } return choose(resolveDefaultAgentId(input.cfg), "default"); }