fix: allow route bindings to override DM session scope (#71750)

Co-authored-by: 따온이네 맥북프로 <tulisy@ttaon-ine-ui-MacBookPro.local>
This commit is contained in:
Poo-Squirry
2026-04-26 06:08:59 +09:00
committed by GitHub
parent bf7d156bb0
commit a1cb8d50ba
8 changed files with 199 additions and 4 deletions

View File

@@ -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: {

View File

@@ -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.",

View File

@@ -520,6 +520,10 @@ export const FIELD_HELP: Record<string, string> = {
'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":

View File

@@ -346,6 +346,8 @@ export const FIELD_LABELS: Record<string, string> = {
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",

View File

@@ -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 = {

View File

@@ -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();

View File

@@ -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",

View File

@@ -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<typeof buildAgentSessionKey>[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);
}
}