feat: multi-agent routing + multi-account providers

This commit is contained in:
Peter Steinberger
2026-01-06 18:25:37 +00:00
parent 50d4b17417
commit dbfa316d19
129 changed files with 3760 additions and 1126 deletions

View File

@@ -40,8 +40,10 @@ import { enqueueSystemEvent } from "../infra/system-events.js";
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { createSubsystemLogger, getChildLogger } from "../logging.js";
import { toLocationContext } from "../providers/location.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "./accounts.js";
import { setActiveWebListener } from "./active-listener.js";
import { monitorWebInbox } from "./inbound.js";
import { loadWebMedia } from "./media.js";
@@ -123,6 +125,8 @@ export type WebMonitorTuning = {
heartbeatSeconds?: number;
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
statusSink?: (status: WebProviderStatus) => void;
/** WhatsApp account id. Default: "default". */
accountId?: string;
};
const formatDuration = (ms: number) =>
@@ -458,7 +462,7 @@ function getSessionRecipients(cfg: ReturnType<typeof loadConfig>) {
.filter(([key]) => !isGroupKey(key) && !isCronKey(key))
.map(([_, entry]) => ({
to:
entry?.lastChannel === "whatsapp" && entry?.lastTo
entry?.lastProvider === "whatsapp" && entry?.lastTo
? normalizeE164(entry.lastTo)
: "",
updatedAt: entry?.updatedAt ?? 0,
@@ -762,7 +766,22 @@ export async function monitorWebProvider(
});
};
emitStatus();
const cfg = loadConfig();
const baseCfg = loadConfig();
const account = resolveWhatsAppAccount({
cfg: baseCfg,
accountId: tuning.accountId,
});
const cfg = {
...baseCfg,
whatsapp: {
...baseCfg.whatsapp,
allowFrom: account.allowFrom,
groupAllowFrom: account.groupAllowFrom,
groupPolicy: account.groupPolicy,
textChunkLimit: account.textChunkLimit,
groups: account.groups,
},
} satisfies ReturnType<typeof loadConfig>;
const configuredMaxMb = cfg.agent?.mediaMaxMb;
const maxMediaBytes =
typeof configuredMaxMb === "number" && configuredMaxMb > 0
@@ -774,7 +793,6 @@ export async function monitorWebProvider(
);
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const mentionConfig = buildMentionConfig(cfg);
const sessionStorePath = resolveStorePath(cfg.session?.store);
const groupHistoryLimit =
cfg.routing?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT;
const groupHistories = new Map<
@@ -853,7 +871,7 @@ export async function monitorWebProvider(
resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Surface: "whatsapp",
Provider: "whatsapp",
});
const resolveGroupPolicyFor = (conversationId: string) => {
@@ -861,7 +879,7 @@ export async function monitorWebProvider(
resolveGroupResolution(conversationId)?.id ?? conversationId;
return resolveProviderGroupPolicy({
cfg,
surface: "whatsapp",
provider: "whatsapp",
groupId,
});
};
@@ -871,20 +889,22 @@ export async function monitorWebProvider(
resolveGroupResolution(conversationId)?.id ?? conversationId;
return resolveProviderGroupRequireMention({
cfg,
surface: "whatsapp",
provider: "whatsapp",
groupId,
});
};
const resolveGroupActivationFor = (conversationId: string) => {
const key =
resolveGroupResolution(conversationId)?.key ??
(conversationId.startsWith("group:")
? conversationId
: `whatsapp:group:${conversationId}`);
const store = loadSessionStore(sessionStorePath);
const entry = store[key];
const requireMention = resolveGroupRequireMentionFor(conversationId);
const resolveGroupActivationFor = (params: {
agentId: string;
sessionKey: string;
conversationId: string;
}) => {
const storePath = resolveStorePath(cfg.session?.store, {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const requireMention = resolveGroupRequireMentionFor(params.conversationId);
const defaultActivation = requireMention === false ? "always" : "mention";
return (
normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation
@@ -1020,7 +1040,7 @@ export async function monitorWebProvider(
// Wrap with standardized envelope for the agent.
return formatAgentEnvelope({
surface: "WhatsApp",
provider: "WhatsApp",
from:
msg.chatType === "group"
? msg.from
@@ -1030,7 +1050,10 @@ export async function monitorWebProvider(
});
};
const processMessage = async (msg: WebInboundMsg) => {
const processMessage = async (
msg: WebInboundMsg,
route: ReturnType<typeof resolveAgentRoute>,
) => {
status.lastMessageAt = Date.now();
status.lastEventAt = status.lastMessageAt;
emitStatus();
@@ -1039,14 +1062,14 @@ export async function monitorWebProvider(
let shouldClearGroupHistory = false;
if (msg.chatType === "group") {
const history = groupHistories.get(conversationId) ?? [];
const history = groupHistories.get(route.sessionKey) ?? [];
const historyWithoutCurrent =
history.length > 0 ? history.slice(0, -1) : [];
if (historyWithoutCurrent.length > 0) {
const historyText = historyWithoutCurrent
.map((m) =>
formatAgentEnvelope({
surface: "WhatsApp",
provider: "WhatsApp",
from: conversationId,
timestamp: m.timestamp,
body: `${m.sender}: ${m.body}`,
@@ -1096,8 +1119,9 @@ export async function monitorWebProvider(
if (msg.chatType !== "group") {
const sessionCfg = cfg.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
const to = (() => {
if (msg.senderE164) return normalizeE164(msg.senderE164);
// In direct chats, `msg.from` is already the canonical conversation id,
@@ -1109,12 +1133,18 @@ export async function monitorWebProvider(
if (to) {
const task = updateLastRoute({
storePath,
sessionKey: mainKey,
channel: "whatsapp",
sessionKey: route.mainSessionKey,
provider: "whatsapp",
to,
accountId: route.accountId,
}).catch((err) => {
replyLogger.warn(
{ error: formatError(err), storePath, sessionKey: mainKey, to },
{
error: formatError(err),
storePath,
sessionKey: route.mainSessionKey,
to,
},
"failed updating last route",
);
});
@@ -1200,6 +1230,8 @@ export async function monitorWebProvider(
Body: combinedBody,
From: msg.from,
To: msg.to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
MessageSid: msg.id,
ReplyToId: msg.replyToId,
ReplyToBody: msg.replyToBody,
@@ -1211,14 +1243,14 @@ export async function monitorWebProvider(
GroupSubject: msg.groupSubject,
GroupMembers: formatGroupMembers(
msg.groupParticipants,
groupMemberNames.get(conversationId),
groupMemberNames.get(route.sessionKey),
msg.senderE164,
),
SenderName: msg.senderName,
SenderE164: msg.senderE164,
WasMentioned: msg.wasMentioned,
...(msg.location ? toLocationContext(msg.location) : {}),
Surface: "whatsapp",
Provider: "whatsapp",
},
cfg,
dispatcher,
@@ -1233,7 +1265,7 @@ export async function monitorWebProvider(
typingController?.markDispatchIdle();
if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(conversationId, []);
groupHistories.set(route.sessionKey, []);
}
logVerbose(
"Skipping auto-reply: silent token or no text/media returned from resolver",
@@ -1242,12 +1274,14 @@ export async function monitorWebProvider(
}
if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(conversationId, []);
groupHistories.set(route.sessionKey, []);
}
};
const listener = await (listenerFactory ?? monitorWebInbox)({
verbose,
accountId: account.accountId,
authDir: account.authDir,
onMessage: async (msg) => {
handledMessages += 1;
lastMessageAt = Date.now();
@@ -1256,6 +1290,28 @@ export async function monitorWebProvider(
emitStatus();
_lastInboundMsg = msg;
const conversationId = msg.conversationId ?? msg.from;
const peerId =
msg.chatType === "group"
? conversationId
: (() => {
if (msg.senderE164) {
return normalizeE164(msg.senderE164) ?? msg.senderE164;
}
if (msg.from.includes("@")) {
return jidToE164(msg.from) ?? msg.from;
}
return normalizeE164(msg.from) ?? msg.from;
})();
const route = resolveAgentRoute({
cfg,
provider: "whatsapp",
accountId: msg.accountId,
peer: {
kind: msg.chatType === "group" ? "group" : "dm",
id: peerId,
},
});
const groupHistoryKey = route.sessionKey;
// Same-phone mode logging retained
if (msg.from === msg.to) {
@@ -1282,7 +1338,33 @@ export async function monitorWebProvider(
);
return;
}
noteGroupMember(conversationId, msg.senderE164, msg.senderName);
{
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const task = updateLastRoute({
storePath,
sessionKey: route.sessionKey,
provider: "whatsapp",
to: conversationId,
accountId: route.accountId,
}).catch((err) => {
replyLogger.warn(
{
error: formatError(err),
storePath,
sessionKey: route.sessionKey,
to: conversationId,
},
"failed updating last route",
);
});
backgroundTasks.add(task);
void task.finally(() => {
backgroundTasks.delete(task);
});
}
noteGroupMember(groupHistoryKey, msg.senderE164, msg.senderName);
const commandBody = stripMentionsForCommand(msg.body, msg.selfE164);
const activationCommand = parseActivationCommand(commandBody);
const isOwner = isOwnerSender(msg);
@@ -1299,7 +1381,7 @@ export async function monitorWebProvider(
if (!shouldBypassMention) {
const history =
groupHistories.get(conversationId) ??
groupHistories.get(groupHistoryKey) ??
([] as Array<{
sender: string;
body: string;
@@ -1311,7 +1393,7 @@ export async function monitorWebProvider(
timestamp: msg.timestamp,
});
while (history.length > groupHistoryLimit) history.shift();
groupHistories.set(conversationId, history);
groupHistories.set(groupHistoryKey, history);
}
const mentionDebug = debugMention(msg, mentionConfig);
@@ -1325,7 +1407,11 @@ export async function monitorWebProvider(
);
const wasMentioned = mentionDebug.wasMentioned;
msg.wasMentioned = wasMentioned;
const activation = resolveGroupActivationFor(conversationId);
const activation = resolveGroupActivationFor({
agentId: route.agentId,
sessionKey: route.sessionKey,
conversationId,
});
const requireMention = activation !== "always";
if (!shouldBypassMention && requireMention && !wasMentioned) {
logVerbose(
@@ -1335,7 +1421,7 @@ export async function monitorWebProvider(
}
}
return processMessage(msg);
return processMessage(msg, route);
},
});
@@ -1346,12 +1432,18 @@ export async function monitorWebProvider(
emitStatus();
// Surface a concise connection event for the next main-session turn/heartbeat.
const { e164: selfE164 } = readWebSelfId();
const { e164: selfE164 } = readWebSelfId(account.authDir);
const connectRoute = resolveAgentRoute({
cfg,
provider: "whatsapp",
accountId: account.accountId,
});
enqueueSystemEvent(
`WhatsApp gateway connected${selfE164 ? ` as ${selfE164}` : ""}.`,
{ sessionKey: connectRoute.sessionKey },
);
setActiveWebListener(listener);
setActiveWebListener(account.accountId, listener);
unregisterUnhandled = registerUnhandledRejectionHandler((reason) => {
if (!isLikelyWhatsAppCryptoError(reason)) return false;
const errorStr = formatError(reason);
@@ -1368,7 +1460,7 @@ export async function monitorWebProvider(
});
const closeListener = async () => {
setActiveWebListener(null);
setActiveWebListener(account.accountId, null);
if (unregisterUnhandled) {
unregisterUnhandled();
unregisterUnhandled = null;
@@ -1388,7 +1480,7 @@ export async function monitorWebProvider(
if (keepAlive) {
heartbeat = setInterval(() => {
const authAgeMs = getWebAuthAgeMs();
const authAgeMs = getWebAuthAgeMs(account.authDir);
const minutesSinceLastMessage = lastMessageAt
? Math.floor((Date.now() - lastMessageAt) / 60000)
: null;