mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix: allow route bindings to override DM session scope (#71750)
Co-authored-by: 따온이네 맥북프로 <tulisy@ttaon-ine-ui-MacBookPro.local>
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user