From a1cb8d50ba8d68951424185e361ce6ec31c7a19d Mon Sep 17 00:00:00 2001 From: Poo-Squirry Date: Sun, 26 Apr 2026 06:08:59 +0900 Subject: [PATCH] fix: allow route bindings to override DM session scope (#71750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 따온이네 맥북프로 --- src/config/config.acp-binding-cutover.test.ts | 21 ++++ src/config/schema.base.generated.ts | 42 +++++++ src/config/schema.help.ts | 4 + src/config/schema.labels.ts | 2 + src/config/types.agents.ts | 6 +- src/config/zod-schema.agents.ts | 14 +++ src/routing/resolve-route.test.ts | 103 ++++++++++++++++++ src/routing/resolve-route.ts | 11 +- 8 files changed, 199 insertions(+), 4 deletions(-) diff --git a/src/config/config.acp-binding-cutover.test.ts b/src/config/config.acp-binding-cutover.test.ts index d0894aacbf5..cbac3afb4b2 100644 --- a/src/config/config.acp-binding-cutover.test.ts +++ b/src/config/config.acp-binding-cutover.test.ts @@ -46,6 +46,27 @@ describe("ACP binding cutover schema", () => { expect(parsed.success).toBe(true); }); + it("accepts route binding session dmScope overrides", () => { + const parsed = OpenClawSchema.safeParse({ + bindings: [ + { + type: "route", + agentId: "main", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "1497598990336790559" }, + }, + session: { + dmScope: "per-account-channel-peer", + }, + }, + ], + }); + + expect(parsed.success).toBe(true); + }); + it("rejects legacy Discord channel-local ACP binding fields", () => { const parsed = OpenClawSchema.safeParse({ channels: { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 722646c7c2e..d85e0b44457 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -18029,6 +18029,38 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", }, + session: { + type: "object", + properties: { + dmScope: { + anyOf: [ + { + type: "string", + const: "main", + }, + { + type: "string", + const: "per-peer", + }, + { + type: "string", + const: "per-channel-peer", + }, + { + type: "string", + const: "per-account-channel-peer", + }, + ], + title: "Binding Session DM Scope", + description: + 'Optional DM session scope override for this route binding. For example, keep global session.dmScope="main" while using "per-account-channel-peer" for selected direct peers.', + }, + }, + additionalProperties: false, + title: "Binding Session", + description: + "Optional route session overrides for conversations matched by this binding. Use this when a narrow route should keep the same agent but isolate session continuity differently.", + }, }, required: ["agentId", "match"], additionalProperties: false, @@ -25144,6 +25176,16 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.", tags: ["advanced"], }, + "bindings[].session": { + label: "Binding Session", + help: "Optional route session overrides for conversations matched by this binding. Use this when a narrow route should keep the same agent but isolate session continuity differently.", + tags: ["storage"], + }, + "bindings[].session.dmScope": { + label: "Binding Session DM Scope", + help: 'Optional DM session scope override for this route binding. For example, keep global session.dmScope="main" while using "per-account-channel-peer" for selected direct peers.', + tags: ["storage"], + }, "bindings[].match": { label: "Binding Match Rule", help: "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e15371b4909..584eb785f37 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -520,6 +520,10 @@ export const FIELD_HELP: Record = { 'Binding kind. Use "route" (or omit for legacy route entries) for normal routing, and "acp" for persistent ACP conversation bindings.', "bindings[].agentId": "Target agent ID that receives traffic when the corresponding binding match rule is satisfied. Use valid configured agent IDs only so routing does not fail at runtime.", + "bindings[].session": + "Optional route session overrides for conversations matched by this binding. Use this when a narrow route should keep the same agent but isolate session continuity differently.", + "bindings[].session.dmScope": + 'Optional DM session scope override for this route binding. For example, keep global session.dmScope="main" while using "per-account-channel-peer" for selected direct peers.', "bindings[].match": "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", "bindings[].match.channel": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index e2fc55c6959..f0355d3e7cc 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -346,6 +346,8 @@ export const FIELD_LABELS: Record = { bindings: "Bindings", "bindings[].type": "Binding Type", "bindings[].agentId": "Binding Agent ID", + "bindings[].session": "Binding Session", + "bindings[].session.dmScope": "Binding Session DM Scope", "bindings[].match": "Binding Match Rule", "bindings[].match.channel": "Binding Channel", "bindings[].match.accountId": "Binding Account ID", diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index aee07358b89..9a89144afaa 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -9,7 +9,7 @@ import type { AgentModelConfig, AgentSandboxConfig, } from "./types.agents-shared.js"; -import type { HumanDelayConfig, IdentityConfig } from "./types.base.js"; +import type { DmScope, HumanDelayConfig, IdentityConfig } from "./types.base.js"; import type { GroupChatConfig } from "./types.messages.js"; import type { SkillsLimitsConfig } from "./types.skills.js"; import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js"; @@ -50,6 +50,10 @@ export type AgentRouteBinding = { agentId: string; comment?: string; match: AgentBindingMatch; + session?: { + /** Optional session scoping override for conversations matched by this binding. */ + dmScope?: DmScope; + }; }; export type AgentAcpBinding = { diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 05dd217a4aa..2abf5d70a5b 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -35,12 +35,26 @@ const BindingMatchSchema = z }) .strict(); +const BindingSessionSchema = z + .object({ + dmScope: z + .union([ + z.literal("main"), + z.literal("per-peer"), + z.literal("per-channel-peer"), + z.literal("per-account-channel-peer"), + ]) + .optional(), + }) + .strict(); + const RouteBindingSchema = z .object({ type: z.literal("route").optional(), agentId: z.string(), comment: z.string().optional(), match: BindingMatchSchema, + session: BindingSessionSchema.optional(), }) .strict(); diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 923c5611243..b0c2e6f2e1a 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -138,6 +138,109 @@ describe("resolveAgentRoute", () => { }); }); + test("route binding session dmScope isolates selected direct peers without changing agent", () => { + const cfg: OpenClawConfig = { + session: { dmScope: "main" }, + bindings: [ + { + type: "route", + agentId: "main", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "1497598990336790559" }, + }, + session: { dmScope: "per-account-channel-peer" }, + }, + { + type: "route", + agentId: "main", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "389224669418618880" }, + }, + session: { dmScope: "per-account-channel-peer" }, + }, + ], + }; + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "358611388488351744" }, + }), + { + agentId: "main", + sessionKey: "agent:main:main", + matchedBy: "default", + lastRoutePolicy: "main", + }, + ); + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "1497598990336790559" }, + }), + { + agentId: "main", + sessionKey: "agent:main:discord:default:direct:1497598990336790559", + matchedBy: "binding.peer", + lastRoutePolicy: "session", + }, + ); + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "389224669418618880" }, + }), + { + agentId: "main", + sessionKey: "agent:main:discord:default:direct:389224669418618880", + matchedBy: "binding.peer", + lastRoutePolicy: "session", + }, + ); + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: "1494710434396110868" }, + }), + { + agentId: "main", + sessionKey: "agent:main:discord:channel:1494710434396110868", + matchedBy: "default", + lastRoutePolicy: "session", + }, + ); + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "webchat", + accountId: null, + peer: null, + }), + { + agentId: "main", + sessionKey: "agent:main:main", + matchedBy: "default", + lastRoutePolicy: "main", + }, + ); + }); + test.each([ { name: "collapses inbound last-route session keys to main when policy is main", diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 1ee0ba82ee8..d75ad6c127c 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -654,15 +654,20 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId); const bindingsIndex = getEvaluatedBindingIndexForChannelAccount(input.cfg, channel, accountId); - const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => { + const choose = ( + agentId: string, + matchedBy: ResolvedAgentRoute["matchedBy"], + sessionOverride?: { dmScope?: Parameters[0]["dmScope"] }, + ) => { const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); + const effectiveDmScope = sessionOverride?.dmScope ?? dmScope; const sessionKey = normalizeLowercaseStringOrEmpty( buildAgentSessionKey({ agentId: resolvedAgentId, channel, accountId, peer, - dmScope, + dmScope: effectiveDmScope, identityLinks, }), ); @@ -806,7 +811,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (shouldLogDebug) { logDebug(`[routing] match: matchedBy=${tier.matchedBy} agentId=${matched.binding.agentId}`); } - return choose(matched.binding.agentId, tier.matchedBy); + return choose(matched.binding.agentId, tier.matchedBy, matched.binding.session); } }