Refactor groupScope to per-binding instead of global session config

Move groupScope from session.groupScope (global, affects all channels)
to bindings[].groupScope (per-binding, surgical control).

This allows specific channel/peer combinations to route to the main
session while keeping all other group channels isolated. Example:

  bindings: [{
    agentId: "main",
    match: { channel: "irc", peer: { kind: "channel", id: "#clawmune" } },
    groupScope: "main"
  }]

This merges only #clawmune into main while #random stays isolated.
Privacy boundaries are preserved by default (per-channel).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tio 2026-03-13 22:05:12 -04:00
parent 746b1c4651
commit 98f9c4f74f
4 changed files with 11 additions and 9 deletions

View File

@ -41,6 +41,8 @@ export type AgentRouteBinding = {
agentId: string;
comment?: string;
match: AgentBindingMatch;
/** When "main", group/channel messages matching this binding route to the agent's main session. */
groupScope?: "main" | "per-channel";
};
export type AgentAcpBinding = {

View File

@ -40,6 +40,7 @@ const RouteBindingSchema = z
agentId: z.string(),
comment: z.string().optional(),
match: BindingMatchSchema,
groupScope: z.union([z.literal("main"), z.literal("per-channel")]).optional(),
})
.strict();

View File

@ -34,7 +34,6 @@ export const SessionSchema = z
z.literal("per-account-channel-peer"),
])
.optional(),
groupScope: z.union([z.literal("main"), z.literal("per-channel")]).optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(),

View File

@ -628,11 +628,6 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
const memberRoleIds = input.memberRoleIds ?? [];
const memberRoleIdSet = new Set(memberRoleIds);
const dmScope = input.cfg.session?.dmScope ?? "main";
const groupScope =
((input.cfg.session as Record<string, unknown> | undefined)?.groupScope as
| "main"
| "per-channel"
| undefined) ?? "per-channel";
const identityLinks = input.cfg.session?.identityLinks;
const shouldLogDebug = shouldLogVerbose();
const parentPeer = input.parentPeer
@ -666,7 +661,11 @@ 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"],
bindingGroupScope?: "main" | "per-channel",
) => {
const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId);
const sessionKey = buildAgentSessionKey({
agentId: resolvedAgentId,
@ -674,7 +673,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
accountId,
peer,
dmScope,
groupScope,
groupScope: bindingGroupScope ?? "per-channel",
identityLinks,
}).toLowerCase();
const mainSessionKey = buildAgentMainSessionKey({
@ -805,7 +804,8 @@ 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);
const routeBinding = matched.binding as { groupScope?: "main" | "per-channel" };
return choose(matched.binding.agentId, tier.matchedBy, routeBinding.groupScope);
}
}