feat: multi-agent routing + multi-account providers
This commit is contained in:
178
src/routing/resolve-route.test.ts
Normal file
178
src/routing/resolve-route.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveAgentRoute } from "./resolve-route.js";
|
||||
|
||||
describe("resolveAgentRoute", () => {
|
||||
test("defaults to main/default when no bindings exist", () => {
|
||||
const cfg: ClawdbotConfig = {};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "whatsapp",
|
||||
accountId: null,
|
||||
peer: { kind: "dm", id: "+15551234567" },
|
||||
});
|
||||
expect(route.agentId).toBe("main");
|
||||
expect(route.accountId).toBe("default");
|
||||
expect(route.sessionKey).toBe("agent:main:main");
|
||||
expect(route.matchedBy).toBe("default");
|
||||
});
|
||||
|
||||
test("peer binding wins over account binding", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
routing: {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "a",
|
||||
match: {
|
||||
provider: "whatsapp",
|
||||
accountId: "biz",
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "b",
|
||||
match: { provider: "whatsapp", accountId: "biz" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "whatsapp",
|
||||
accountId: "biz",
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
});
|
||||
expect(route.agentId).toBe("a");
|
||||
expect(route.sessionKey).toBe("agent:a:main");
|
||||
expect(route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
test("discord channel peer binding wins over guild binding", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
routing: {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "chan",
|
||||
match: {
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "guild",
|
||||
match: {
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
guildId: "g1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
guildId: "g1",
|
||||
});
|
||||
expect(route.agentId).toBe("chan");
|
||||
expect(route.sessionKey).toBe("agent:chan:discord:channel:c1");
|
||||
expect(route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
test("guild binding wins over account binding when peer not bound", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
routing: {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "guild",
|
||||
match: {
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
guildId: "g1",
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "acct",
|
||||
match: { provider: "discord", accountId: "default" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
guildId: "g1",
|
||||
});
|
||||
expect(route.agentId).toBe("guild");
|
||||
expect(route.matchedBy).toBe("binding.guild");
|
||||
});
|
||||
|
||||
test("missing accountId in binding matches default account only", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
routing: {
|
||||
bindings: [{ agentId: "defaultAcct", match: { provider: "whatsapp" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const defaultRoute = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "whatsapp",
|
||||
accountId: undefined,
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
});
|
||||
expect(defaultRoute.agentId).toBe("defaultAcct");
|
||||
expect(defaultRoute.matchedBy).toBe("binding.account");
|
||||
|
||||
const otherRoute = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "whatsapp",
|
||||
accountId: "biz",
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
});
|
||||
expect(otherRoute.agentId).toBe("main");
|
||||
});
|
||||
|
||||
test("accountId=* matches any account as a provider fallback", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
routing: {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "any",
|
||||
match: { provider: "whatsapp", accountId: "*" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "whatsapp",
|
||||
accountId: "biz",
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
});
|
||||
expect(route.agentId).toBe("any");
|
||||
expect(route.matchedBy).toBe("binding.provider");
|
||||
});
|
||||
|
||||
test("defaultAgentId is used when no binding matches", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
routing: {
|
||||
defaultAgentId: "home",
|
||||
agents: { home: { workspace: "~/clawd-home" } },
|
||||
},
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
provider: "whatsapp",
|
||||
accountId: "biz",
|
||||
peer: { kind: "dm", id: "+1000" },
|
||||
});
|
||||
expect(route.agentId).toBe("home");
|
||||
expect(route.sessionKey).toBe("agent:home:main");
|
||||
});
|
||||
});
|
||||
223
src/routing/resolve-route.ts
Normal file
223
src/routing/resolve-route.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
buildAgentPeerSessionKey,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_KEY,
|
||||
normalizeAgentId,
|
||||
} from "./session-key.js";
|
||||
|
||||
export type RoutePeerKind = "dm" | "group" | "channel";
|
||||
|
||||
export type RoutePeer = {
|
||||
kind: RoutePeerKind;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type ResolveAgentRouteInput = {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: string;
|
||||
accountId?: string | null;
|
||||
peer?: RoutePeer | null;
|
||||
guildId?: string | null;
|
||||
teamId?: string | null;
|
||||
};
|
||||
|
||||
export type ResolvedAgentRoute = {
|
||||
agentId: string;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
/** Internal session key used for persistence + concurrency. */
|
||||
sessionKey: string;
|
||||
/** Convenience alias for direct-chat collapse. */
|
||||
mainSessionKey: string;
|
||||
/** Match description for debugging/logging. */
|
||||
matchedBy:
|
||||
| "binding.peer"
|
||||
| "binding.guild"
|
||||
| "binding.team"
|
||||
| "binding.account"
|
||||
| "binding.provider"
|
||||
| "default";
|
||||
};
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID, DEFAULT_AGENT_ID } from "./session-key.js";
|
||||
|
||||
function normalizeToken(value: string | undefined | null): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeId(value: string | undefined | null): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeAccountId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
return trimmed ? trimmed : DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function matchesAccountId(match: string | undefined, actual: string): boolean {
|
||||
const trimmed = (match ?? "").trim();
|
||||
if (!trimmed) return actual === DEFAULT_ACCOUNT_ID;
|
||||
if (trimmed === "*") return true;
|
||||
return trimmed === actual;
|
||||
}
|
||||
|
||||
export function buildAgentSessionKey(params: {
|
||||
agentId: string;
|
||||
provider: string;
|
||||
peer?: RoutePeer | null;
|
||||
}): string {
|
||||
const provider = normalizeToken(params.provider) || "unknown";
|
||||
const peer = params.peer;
|
||||
return buildAgentPeerSessionKey({
|
||||
agentId: params.agentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
provider,
|
||||
peerKind: peer?.kind ?? "dm",
|
||||
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
|
||||
});
|
||||
}
|
||||
|
||||
function listBindings(cfg: ClawdbotConfig) {
|
||||
const bindings = cfg.routing?.bindings;
|
||||
return Array.isArray(bindings) ? bindings : [];
|
||||
}
|
||||
|
||||
function listAgents(cfg: ClawdbotConfig) {
|
||||
const agents = cfg.routing?.agents;
|
||||
return agents && typeof agents === "object" ? agents : undefined;
|
||||
}
|
||||
|
||||
function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
|
||||
const explicit = cfg.routing?.defaultAgentId?.trim();
|
||||
if (explicit) return explicit;
|
||||
return DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
function pickFirstExistingAgentId(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): string {
|
||||
const normalized = normalizeAgentId(agentId);
|
||||
const agents = listAgents(cfg);
|
||||
if (!agents) return normalized;
|
||||
if (Object.hasOwn(agents, normalized)) return normalized;
|
||||
return normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
function matchesProvider(
|
||||
match: { provider?: string | undefined } | undefined,
|
||||
provider: string,
|
||||
): boolean {
|
||||
const key = normalizeToken(match?.provider);
|
||||
if (!key) return false;
|
||||
return key === provider;
|
||||
}
|
||||
|
||||
function matchesPeer(
|
||||
match: { peer?: { kind?: string; id?: string } | undefined } | undefined,
|
||||
peer: RoutePeer,
|
||||
): boolean {
|
||||
const m = match?.peer;
|
||||
if (!m) return false;
|
||||
const kind = normalizeToken(m.kind);
|
||||
const id = normalizeId(m.id);
|
||||
if (!kind || !id) return false;
|
||||
return kind === peer.kind && id === peer.id;
|
||||
}
|
||||
|
||||
function matchesGuild(
|
||||
match: { guildId?: string | undefined } | undefined,
|
||||
guildId: string,
|
||||
): boolean {
|
||||
const id = normalizeId(match?.guildId);
|
||||
if (!id) return false;
|
||||
return id === guildId;
|
||||
}
|
||||
|
||||
function matchesTeam(
|
||||
match: { teamId?: string | undefined } | undefined,
|
||||
teamId: string,
|
||||
): boolean {
|
||||
const id = normalizeId(match?.teamId);
|
||||
if (!id) return false;
|
||||
return id === teamId;
|
||||
}
|
||||
|
||||
export function resolveAgentRoute(
|
||||
input: ResolveAgentRouteInput,
|
||||
): ResolvedAgentRoute {
|
||||
const provider = normalizeToken(input.provider);
|
||||
const accountId = normalizeAccountId(input.accountId);
|
||||
const peer = input.peer
|
||||
? { kind: input.peer.kind, id: normalizeId(input.peer.id) }
|
||||
: null;
|
||||
const guildId = normalizeId(input.guildId);
|
||||
const teamId = normalizeId(input.teamId);
|
||||
|
||||
const bindings = listBindings(input.cfg).filter((binding) => {
|
||||
if (!binding || typeof binding !== "object") return false;
|
||||
if (!matchesProvider(binding.match, provider)) return false;
|
||||
return matchesAccountId(binding.match?.accountId, accountId);
|
||||
});
|
||||
|
||||
const choose = (
|
||||
agentId: string,
|
||||
matchedBy: ResolvedAgentRoute["matchedBy"],
|
||||
) => {
|
||||
const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId);
|
||||
return {
|
||||
agentId: resolvedAgentId,
|
||||
provider,
|
||||
accountId,
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
provider,
|
||||
peer,
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
matchedBy,
|
||||
};
|
||||
};
|
||||
|
||||
if (peer) {
|
||||
const peerMatch = bindings.find((b) => matchesPeer(b.match, peer));
|
||||
if (peerMatch) return choose(peerMatch.agentId, "binding.peer");
|
||||
}
|
||||
|
||||
if (guildId) {
|
||||
const guildMatch = bindings.find((b) => matchesGuild(b.match, guildId));
|
||||
if (guildMatch) return choose(guildMatch.agentId, "binding.guild");
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const teamMatch = bindings.find((b) => matchesTeam(b.match, teamId));
|
||||
if (teamMatch) return choose(teamMatch.agentId, "binding.team");
|
||||
}
|
||||
|
||||
const accountMatch = bindings.find(
|
||||
(b) =>
|
||||
b.match?.accountId?.trim() !== "*" &&
|
||||
!b.match?.peer &&
|
||||
!b.match?.guildId &&
|
||||
!b.match?.teamId,
|
||||
);
|
||||
if (accountMatch) return choose(accountMatch.agentId, "binding.account");
|
||||
|
||||
const anyAccountMatch = bindings.find(
|
||||
(b) =>
|
||||
b.match?.accountId?.trim() === "*" &&
|
||||
!b.match?.peer &&
|
||||
!b.match?.guildId &&
|
||||
!b.match?.teamId,
|
||||
);
|
||||
if (anyAccountMatch)
|
||||
return choose(anyAccountMatch.agentId, "binding.provider");
|
||||
|
||||
return choose(resolveDefaultAgentId(input.cfg), "default");
|
||||
}
|
||||
77
src/routing/session-key.ts
Normal file
77
src/routing/session-key.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export const DEFAULT_AGENT_ID = "main";
|
||||
export const DEFAULT_MAIN_KEY = "main";
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export type ParsedAgentSessionKey = {
|
||||
agentId: string;
|
||||
rest: string;
|
||||
};
|
||||
|
||||
export function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_AGENT_ID;
|
||||
// Keep it path-safe + shell-friendly.
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
// Best-effort fallback: collapse invalid characters to "-"
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || DEFAULT_AGENT_ID
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAgentSessionKey(
|
||||
sessionKey: string | undefined | null,
|
||||
): ParsedAgentSessionKey | null {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(":").filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
if (parts[0] !== "agent") return null;
|
||||
const agentId = parts[1]?.trim();
|
||||
const rest = parts.slice(2).join(":");
|
||||
if (!agentId || !rest) return null;
|
||||
return { agentId, rest };
|
||||
}
|
||||
|
||||
export function isSubagentSessionKey(
|
||||
sessionKey: string | undefined | null,
|
||||
): boolean {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return false;
|
||||
if (raw.toLowerCase().startsWith("subagent:")) return true;
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
|
||||
}
|
||||
|
||||
export function buildAgentMainSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
}): string {
|
||||
const agentId = normalizeAgentId(params.agentId);
|
||||
const mainKey =
|
||||
(params.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
|
||||
return `agent:${agentId}:${mainKey}`;
|
||||
}
|
||||
|
||||
export function buildAgentPeerSessionKey(params: {
|
||||
agentId: string;
|
||||
mainKey?: string | undefined;
|
||||
provider: string;
|
||||
peerKind?: "dm" | "group" | "channel" | null;
|
||||
peerId?: string | null;
|
||||
}): string {
|
||||
const peerKind = params.peerKind ?? "dm";
|
||||
if (peerKind === "dm") {
|
||||
return buildAgentMainSessionKey({
|
||||
agentId: params.agentId,
|
||||
mainKey: params.mainKey,
|
||||
});
|
||||
}
|
||||
const provider = (params.provider ?? "").trim().toLowerCase() || "unknown";
|
||||
const peerId = (params.peerId ?? "").trim() || "unknown";
|
||||
return `agent:${normalizeAgentId(params.agentId)}:${provider}:${peerKind}:${peerId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user