refactor(routing): centralize inbound last-route policy

This commit is contained in:
Peter Steinberger
2026-03-08 02:02:00 +00:00
parent b2f8f5e4dd
commit 6a8081a7f3
10 changed files with 172 additions and 9 deletions

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import type { ResolvedAgentRoute } from "../routing/resolve-route.js"; import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
import { deriveLastRoutePolicy } from "../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { import {
ensureConfiguredAcpBindingSession, ensureConfiguredAcpBindingSession,
@@ -50,6 +51,10 @@ export function resolveConfiguredAcpRoute(params: {
...params.route, ...params.route,
sessionKey: boundSessionKey, sessionKey: boundSessionKey,
agentId: boundAgentId, agentId: boundAgentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: params.route.mainSessionKey,
}),
matchedBy: "binding.channel", matchedBy: "binding.channel",
}, },
}; };

View File

@@ -30,6 +30,7 @@ describe("discord route resolution helpers", () => {
accountId: "default", accountId: "default",
sessionKey: "agent:main:discord:channel:c1", sessionKey: "agent:main:discord:channel:c1",
mainSessionKey: "agent:main:main", mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default", matchedBy: "default",
}; };
@@ -54,6 +55,7 @@ describe("discord route resolution helpers", () => {
accountId: "default", accountId: "default",
sessionKey: "agent:main:discord:channel:c1", sessionKey: "agent:main:discord:channel:c1",
mainSessionKey: "agent:main:main", mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default", matchedBy: "default",
}; };
const configuredRoute = { const configuredRoute = {
@@ -62,6 +64,7 @@ describe("discord route resolution helpers", () => {
agentId: "worker", agentId: "worker",
sessionKey: "agent:worker:discord:channel:c1", sessionKey: "agent:worker:discord:channel:c1",
mainSessionKey: "agent:worker:main", mainSessionKey: "agent:worker:main",
lastRoutePolicy: "session" as const,
matchedBy: "binding.peer" as const, matchedBy: "binding.peer" as const,
}, },
}; };

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { import {
deriveLastRoutePolicy,
resolveAgentRoute, resolveAgentRoute,
type ResolvedAgentRoute, type ResolvedAgentRoute,
type RoutePeer, type RoutePeer,
@@ -90,6 +91,10 @@ export function resolveDiscordEffectiveRoute(params: {
...params.route, ...params.route,
sessionKey: boundSessionKey, sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey), agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: params.route.mainSessionKey,
}),
...(params.matchedBy ? { matchedBy: params.matchedBy } : {}), ...(params.matchedBy ? { matchedBy: params.matchedBy } : {}),
}; };
} }

View File

@@ -2,7 +2,11 @@ import { describe, expect, test, vi } from "vitest";
import type { ChatType } from "../channels/chat-type.js"; import type { ChatType } from "../channels/chat-type.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import * as routingBindings from "./bindings.js"; import * as routingBindings from "./bindings.js";
import { resolveAgentRoute } from "./resolve-route.js"; import {
deriveLastRoutePolicy,
resolveAgentRoute,
resolveInboundLastRouteSessionKey,
} from "./resolve-route.js";
describe("resolveAgentRoute", () => { describe("resolveAgentRoute", () => {
const resolveDiscordGuildRoute = (cfg: OpenClawConfig) => const resolveDiscordGuildRoute = (cfg: OpenClawConfig) =>
@@ -25,6 +29,7 @@ describe("resolveAgentRoute", () => {
expect(route.agentId).toBe("main"); expect(route.agentId).toBe("main");
expect(route.accountId).toBe("default"); expect(route.accountId).toBe("default");
expect(route.sessionKey).toBe("agent:main:main"); expect(route.sessionKey).toBe("agent:main:main");
expect(route.lastRoutePolicy).toBe("main");
expect(route.matchedBy).toBe("default"); expect(route.matchedBy).toBe("default");
}); });
@@ -47,9 +52,47 @@ describe("resolveAgentRoute", () => {
peer: { kind: "direct", id: "+15551234567" }, peer: { kind: "direct", id: "+15551234567" },
}); });
expect(route.sessionKey).toBe(testCase.expected); expect(route.sessionKey).toBe(testCase.expected);
expect(route.lastRoutePolicy).toBe("session");
} }
}); });
test("resolveInboundLastRouteSessionKey follows route policy", () => {
expect(
resolveInboundLastRouteSessionKey({
route: {
mainSessionKey: "agent:main:main",
lastRoutePolicy: "main",
},
sessionKey: "agent:main:discord:direct:user-1",
}),
).toBe("agent:main:main");
expect(
resolveInboundLastRouteSessionKey({
route: {
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
},
sessionKey: "agent:main:telegram:atlas:direct:123",
}),
).toBe("agent:main:telegram:atlas:direct:123");
});
test("deriveLastRoutePolicy collapses only main-session routes", () => {
expect(
deriveLastRoutePolicy({
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
}),
).toBe("main");
expect(
deriveLastRoutePolicy({
sessionKey: "agent:main:telegram:direct:123",
mainSessionKey: "agent:main:main",
}),
).toBe("session");
});
test("identityLinks applies to direct-message scopes", () => { test("identityLinks applies to direct-message scopes", () => {
const cases = [ const cases = [
{ {

View File

@@ -44,6 +44,8 @@ export type ResolvedAgentRoute = {
sessionKey: string; sessionKey: string;
/** Convenience alias for direct-chat collapse. */ /** Convenience alias for direct-chat collapse. */
mainSessionKey: string; mainSessionKey: string;
/** Which session should receive inbound last-route updates. */
lastRoutePolicy: "main" | "session";
/** Match description for debugging/logging. */ /** Match description for debugging/logging. */
matchedBy: matchedBy:
| "binding.peer" | "binding.peer"
@@ -58,6 +60,20 @@ export type ResolvedAgentRoute = {
export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js"; export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js";
export function deriveLastRoutePolicy(params: {
sessionKey: string;
mainSessionKey: string;
}): ResolvedAgentRoute["lastRoutePolicy"] {
return params.sessionKey === params.mainSessionKey ? "main" : "session";
}
export function resolveInboundLastRouteSessionKey(params: {
route: Pick<ResolvedAgentRoute, "lastRoutePolicy" | "mainSessionKey">;
sessionKey: string;
}): string {
return params.route.lastRoutePolicy === "main" ? params.route.mainSessionKey : params.sessionKey;
}
function normalizeToken(value: string | undefined | null): string { function normalizeToken(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase(); return (value ?? "").trim().toLowerCase();
} }
@@ -662,6 +678,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
accountId, accountId,
sessionKey, sessionKey,
mainSessionKey, mainSessionKey,
lastRoutePolicy: deriveLastRoutePolicy({ sessionKey, mainSessionKey }),
matchedBy, matchedBy,
}; };
if (routeCache && routeCacheKey) { if (routeCache && routeCacheKey) {

View File

@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js"; import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
vi.mock("../channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
describe("buildTelegramMessageContext named-account DM fallback", () => { describe("buildTelegramMessageContext named-account DM fallback", () => {
const baseCfg = { const baseCfg = {
agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } }, agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/openclaw" } },
@@ -11,8 +16,16 @@ describe("buildTelegramMessageContext named-account DM fallback", () => {
afterEach(() => { afterEach(() => {
clearRuntimeConfigSnapshot(); clearRuntimeConfigSnapshot();
recordInboundSessionMock.mockClear();
}); });
function getLastUpdateLastRoute(): { sessionKey?: string } | undefined {
const callArgs = recordInboundSessionMock.mock.calls.at(-1)?.[0] as {
updateLastRoute?: { sessionKey?: string };
};
return callArgs?.updateLastRoute;
}
it("allows DM through for a named account with no explicit binding", async () => { it("allows DM through for a named account with no explicit binding", async () => {
setRuntimeConfigSnapshot(baseCfg); setRuntimeConfigSnapshot(baseCfg);
@@ -51,6 +64,25 @@ describe("buildTelegramMessageContext named-account DM fallback", () => {
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386"); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
}); });
it("keeps named-account fallback lastRoute on the isolated DM session", async () => {
setRuntimeConfigSnapshot(baseCfg);
const ctx = await buildTelegramMessageContextForTest({
cfg: baseCfg,
accountId: "atlas",
message: {
message_id: 1,
chat: { id: 814912386, type: "private" },
date: 1700000000,
text: "hello",
from: { id: 814912386, first_name: "Alice" },
},
});
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
expect(getLastUpdateLastRoute()?.sessionKey).toBe("agent:main:telegram:atlas:direct:814912386");
});
it("isolates sessions between named accounts that share the default agent", async () => { it("isolates sessions between named accounts that share the default agent", async () => {
setRuntimeConfigSnapshot(baseCfg); setRuntimeConfigSnapshot(baseCfg);

View File

@@ -39,7 +39,11 @@ import type {
} from "../config/types.js"; } from "../config/types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js"; import { recordChannelActivity } from "../infra/channel-activity.js";
import { buildAgentSessionKey } from "../routing/resolve-route.js"; import {
buildAgentSessionKey,
deriveLastRoutePolicy,
resolveInboundLastRouteSessionKey,
} from "../routing/resolve-route.js";
import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js"; import { DEFAULT_ACCOUNT_ID, resolveThreadSessionKeys } from "../routing/session-key.js";
import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js";
import { withTelegramApiErrorLogging } from "./api-logging.js"; import { withTelegramApiErrorLogging } from "./api-logging.js";
@@ -362,6 +366,14 @@ export const buildTelegramMessageContext = async ({
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` }) ? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null; : null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey; const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
route = {
...route,
sessionKey,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey,
mainSessionKey: route.mainSessionKey,
}),
};
const mentionRegexes = buildMentionRegexes(cfg, route.agentId); const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
// Compute requireMention after access checks and final route selection. // Compute requireMention after access checks and final route selection.
const activationOverride = resolveGroupActivation({ const activationOverride = resolveGroupActivation({
@@ -832,6 +844,10 @@ export const buildTelegramMessageContext = async ({
normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0], normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0],
}) })
: null; : null;
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route,
sessionKey,
});
await recordInboundSession({ await recordInboundSession({
storePath, storePath,
@@ -839,14 +855,14 @@ export const buildTelegramMessageContext = async ({
ctx: ctxPayload, ctx: ctxPayload,
updateLastRoute: !isGroup updateLastRoute: !isGroup
? { ? {
sessionKey: route.mainSessionKey, sessionKey: updateLastRouteSessionKey,
channel: "telegram", channel: "telegram",
to: `telegram:${chatId}`, to: `telegram:${chatId}`,
accountId: route.accountId, accountId: route.accountId,
// Preserve DM topic threadId for replies (fixes #8891) // Preserve DM topic threadId for replies (fixes #8891)
threadId: dmThreadId != null ? String(dmThreadId) : undefined, threadId: dmThreadId != null ? String(dmThreadId) : undefined,
mainDmOwnerPin: mainDmOwnerPin:
pinnedMainDmOwner && senderId updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId
? { ? {
ownerRecipient: pinnedMainDmOwner, ownerRecipient: pinnedMainDmOwner,
senderRecipient: senderId, senderRecipient: senderId,

View File

@@ -4,6 +4,7 @@ import { logVerbose } from "../globals.js";
import { getSessionBindingService } from "../infra/outbound/session-binding-service.js"; import { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
import { import {
buildAgentSessionKey, buildAgentSessionKey,
deriveLastRoutePolicy,
pickFirstExistingAgentId, pickFirstExistingAgentId,
resolveAgentRoute, resolveAgentRoute,
} from "../routing/resolve-route.js"; } from "../routing/resolve-route.js";
@@ -67,6 +68,19 @@ export function resolveTelegramConversationRoute(params: {
mainSessionKey: buildAgentMainSessionKey({ mainSessionKey: buildAgentMainSessionKey({
agentId: topicAgentId, agentId: topicAgentId,
}).toLowerCase(), }).toLowerCase(),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: buildAgentSessionKey({
agentId: topicAgentId,
channel: "telegram",
accountId: params.accountId,
peer: { kind: params.isGroup ? "group" : "direct", id: peerId },
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
}).toLowerCase(),
mainSessionKey: buildAgentMainSessionKey({
agentId: topicAgentId,
}).toLowerCase(),
}),
}; };
logVerbose( logVerbose(
`telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`, `telegram: topic route override: topic=${params.resolvedThreadId ?? params.replyThreadId} agent=${topicAgentId} sessionKey=${route.sessionKey}`,
@@ -103,6 +117,10 @@ export function resolveTelegramConversationRoute(params: {
...route, ...route,
sessionKey: boundSessionKey, sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey), agentId: resolveAgentIdFromSessionKey(boundSessionKey),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: route.mainSessionKey,
}),
matchedBy: "binding.channel", matchedBy: "binding.channel",
}; };
configuredBinding = null; configuredBinding = null;

View File

@@ -1,6 +1,6 @@
import type { loadConfig } from "../../../config/config.js"; import type { loadConfig } from "../../../config/config.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; import type { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { buildAgentSessionKey } from "../../../routing/resolve-route.js"; import { buildAgentSessionKey, deriveLastRoutePolicy } from "../../../routing/resolve-route.js";
import { import {
buildAgentMainSessionKey, buildAgentMainSessionKey,
DEFAULT_MAIN_KEY, DEFAULT_MAIN_KEY,
@@ -70,6 +70,23 @@ export async function maybeBroadcastMessage(params: {
agentId: normalizedAgentId, agentId: normalizedAgentId,
mainKey: DEFAULT_MAIN_KEY, mainKey: DEFAULT_MAIN_KEY,
}), }),
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
channel: "whatsapp",
accountId: params.route.accountId,
peer: {
kind: params.msg.chatType === "group" ? "group" : "direct",
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
identityLinks: params.cfg.session?.identityLinks,
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,
mainKey: DEFAULT_MAIN_KEY,
}),
}),
}; };
try { try {

View File

@@ -19,7 +19,10 @@ import { recordSessionMetaFromInbound } from "../../../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import type { getChildLogger } from "../../../logging.js"; import type { getChildLogger } from "../../../logging.js";
import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
import type { resolveAgentRoute } from "../../../routing/resolve-route.js"; import {
resolveInboundLastRouteSessionKey,
type resolveAgentRoute,
} from "../../../routing/resolve-route.js";
import { import {
readStoreAllowFromForDmPolicy, readStoreAllowFromForDmPolicy,
resolvePinnedMainDmOwnerFromAllowlist, resolvePinnedMainDmOwnerFromAllowlist,
@@ -339,9 +342,13 @@ export async function processMessage(params: {
}); });
const shouldUpdateMainLastRoute = const shouldUpdateMainLastRoute =
!pinnedMainDmRecipient || pinnedMainDmRecipient === dmRouteTarget; !pinnedMainDmRecipient || pinnedMainDmRecipient === dmRouteTarget;
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
route: params.route,
sessionKey: params.route.sessionKey,
});
if ( if (
dmRouteTarget && dmRouteTarget &&
params.route.sessionKey === params.route.mainSessionKey && inboundLastRouteSessionKey === params.route.mainSessionKey &&
shouldUpdateMainLastRoute shouldUpdateMainLastRoute
) { ) {
updateLastRouteInBackground({ updateLastRouteInBackground({
@@ -357,7 +364,7 @@ export async function processMessage(params: {
}); });
} else if ( } else if (
dmRouteTarget && dmRouteTarget &&
params.route.sessionKey === params.route.mainSessionKey && inboundLastRouteSessionKey === params.route.mainSessionKey &&
pinnedMainDmRecipient pinnedMainDmRecipient
) { ) {
logVerbose( logVerbose(