Agents: add account-scoped bind and routing commands (#27195)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad35a458a5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-26 02:36:56 -05:00
committed by GitHub
parent c5d040bbea
commit 96c7702526
17 changed files with 1133 additions and 24 deletions

View File

@@ -8,16 +8,51 @@ import type { ChannelChoice } from "./onboard-types.js";
function bindingMatchKey(match: AgentBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const identityKey = bindingMatchIdentityKey(match);
return [identityKey, accountId].join("|");
}
function bindingMatchIdentityKey(match: AgentBinding["match"]) {
const roles = Array.isArray(match.roles)
? Array.from(
new Set(
match.roles
.map((role) => role.trim())
.filter(Boolean)
.toSorted(),
),
)
: [];
return [
match.channel,
accountId,
match.peer?.kind ?? "",
match.peer?.id ?? "",
match.guildId ?? "",
match.teamId ?? "",
roles.join(","),
].join("|");
}
function canUpgradeBindingAccountScope(params: {
existing: AgentBinding;
incoming: AgentBinding;
normalizedIncomingAgentId: string;
}): boolean {
if (!params.incoming.match.accountId?.trim()) {
return false;
}
if (params.existing.match.accountId?.trim()) {
return false;
}
if (normalizeAgentId(params.existing.agentId) !== params.normalizedIncomingAgentId) {
return false;
}
return (
bindingMatchIdentityKey(params.existing.match) ===
bindingMatchIdentityKey(params.incoming.match)
);
}
export function describeBinding(binding: AgentBinding) {
const match = binding.match;
const parts = [match.channel];
@@ -42,10 +77,11 @@ export function applyAgentBindings(
): {
config: OpenClawConfig;
added: AgentBinding[];
updated: AgentBinding[];
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.bindings ?? [];
const existing = [...(cfg.bindings ?? [])];
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
const key = bindingMatchKey(binding.match);
@@ -55,6 +91,7 @@ export function applyAgentBindings(
}
const added: AgentBinding[] = [];
const updated: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
@@ -70,12 +107,41 @@ export function applyAgentBindings(
}
continue;
}
const upgradeIndex = existing.findIndex((candidate) =>
canUpgradeBindingAccountScope({
existing: candidate,
incoming: binding,
normalizedIncomingAgentId: agentId,
}),
);
if (upgradeIndex >= 0) {
const current = existing[upgradeIndex];
if (!current) {
continue;
}
const previousKey = bindingMatchKey(current.match);
const upgradedBinding: AgentBinding = {
...current,
agentId,
match: {
...current.match,
accountId: binding.match.accountId?.trim(),
},
};
existing[upgradeIndex] = upgradedBinding;
existingMatchMap.delete(previousKey);
existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId);
updated.push(upgradedBinding);
continue;
}
existingMatchMap.set(key, agentId);
added.push({ ...binding, agentId });
}
if (added.length === 0) {
return { config: cfg, added, skipped, conflicts };
if (added.length === 0 && updated.length === 0) {
return { config: cfg, added, updated, skipped, conflicts };
}
return {
@@ -84,11 +150,78 @@ export function applyAgentBindings(
bindings: [...existing, ...added],
},
added,
updated,
skipped,
conflicts,
};
}
export function removeAgentBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
): {
config: OpenClawConfig;
removed: AgentBinding[];
missing: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.bindings ?? [];
const removeIndexes = new Set<number>();
const removed: AgentBinding[] = [];
const missing: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const desiredAgentId = normalizeAgentId(binding.agentId);
const key = bindingMatchKey(binding.match);
let matchedIndex = -1;
let conflictingAgentId: string | null = null;
for (let i = 0; i < existing.length; i += 1) {
if (removeIndexes.has(i)) {
continue;
}
const current = existing[i];
if (!current || bindingMatchKey(current.match) !== key) {
continue;
}
const currentAgentId = normalizeAgentId(current.agentId);
if (currentAgentId === desiredAgentId) {
matchedIndex = i;
break;
}
conflictingAgentId = currentAgentId;
}
if (matchedIndex >= 0) {
const matched = existing[matchedIndex];
if (matched) {
removeIndexes.add(matchedIndex);
removed.push(matched);
}
continue;
}
if (conflictingAgentId) {
conflicts.push({ binding, existingAgentId: conflictingAgentId });
continue;
}
missing.push(binding);
}
if (removeIndexes.size === 0) {
return { config: cfg, removed, missing, conflicts };
}
const nextBindings = existing.filter((_, index) => !removeIndexes.has(index));
return {
config: {
...cfg,
bindings: nextBindings.length > 0 ? nextBindings : undefined,
},
removed,
missing,
conflicts,
};
}
function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider);
if (!plugin) {
@@ -97,6 +230,33 @@ function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): stri
return resolveChannelDefaultAccountId({ plugin, cfg });
}
function resolveBindingAccountId(params: {
channel: ChannelId;
config: OpenClawConfig;
agentId: string;
explicitAccountId?: string;
}): string | undefined {
const explicitAccountId = params.explicitAccountId?.trim();
if (explicitAccountId) {
return explicitAccountId;
}
const plugin = getChannelPlugin(params.channel);
const pluginAccountId = plugin?.setup?.resolveBindingAccountId?.({
cfg: params.config,
agentId: params.agentId,
});
if (pluginAccountId?.trim()) {
return pluginAccountId.trim();
}
if (plugin?.meta.forceAccountBinding) {
return resolveDefaultAccountId(params.config, params.channel);
}
return undefined;
}
export function buildChannelBindings(params: {
agentId: string;
selection: ChannelChoice[];
@@ -107,14 +267,14 @@ export function buildChannelBindings(params: {
const agentId = normalizeAgentId(params.agentId);
for (const channel of params.selection) {
const match: AgentBinding["match"] = { channel };
const accountId = params.accountIds?.[channel]?.trim();
const accountId = resolveBindingAccountId({
channel,
config: params.config,
agentId,
explicitAccountId: params.accountIds?.[channel],
});
if (accountId) {
match.accountId = accountId;
} else {
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
match.accountId = resolveDefaultAccountId(params.config, channel);
}
}
bindings.push({ agentId, match });
}
@@ -141,17 +301,17 @@ export function parseBindingSpecs(params: {
errors.push(`Unknown channel "${channelRaw}".`);
continue;
}
let accountId = accountRaw?.trim();
let accountId: string | undefined = accountRaw?.trim();
if (accountRaw !== undefined && !accountId) {
errors.push(`Invalid binding "${trimmed}" (empty account id).`);
continue;
}
if (!accountId) {
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
accountId = resolveDefaultAccountId(params.config, channel);
}
}
accountId = resolveBindingAccountId({
channel,
config: params.config,
agentId,
explicitAccountId: accountId,
});
const match: AgentBinding["match"] = { channel };
if (accountId) {
match.accountId = accountId;