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

@@ -47,7 +47,7 @@ describe("chunkText", () => {
});
describe("resolveTextChunkLimit", () => {
it("uses per-surface defaults", () => {
it("uses per-provider defaults", () => {
expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "slack")).toBe(4000);

View File

@@ -4,7 +4,7 @@
import type { ClawdbotConfig } from "../config/config.js";
export type TextChunkSurface =
export type TextChunkProvider =
| "whatsapp"
| "telegram"
| "discord"
@@ -13,7 +13,7 @@ export type TextChunkSurface =
| "imessage"
| "webchat";
const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
whatsapp: 4000,
telegram: 4000,
discord: 2000,
@@ -25,22 +25,22 @@ const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
export function resolveTextChunkLimit(
cfg: ClawdbotConfig | undefined,
surface?: TextChunkSurface,
provider?: TextChunkProvider,
): number {
const surfaceOverride = (() => {
if (!surface) return undefined;
if (surface === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
if (surface === "telegram") return cfg?.telegram?.textChunkLimit;
if (surface === "discord") return cfg?.discord?.textChunkLimit;
if (surface === "slack") return cfg?.slack?.textChunkLimit;
if (surface === "signal") return cfg?.signal?.textChunkLimit;
if (surface === "imessage") return cfg?.imessage?.textChunkLimit;
const providerOverride = (() => {
if (!provider) return undefined;
if (provider === "whatsapp") return cfg?.whatsapp?.textChunkLimit;
if (provider === "telegram") return cfg?.telegram?.textChunkLimit;
if (provider === "discord") return cfg?.discord?.textChunkLimit;
if (provider === "slack") return cfg?.slack?.textChunkLimit;
if (provider === "signal") return cfg?.signal?.textChunkLimit;
if (provider === "imessage") return cfg?.imessage?.textChunkLimit;
return undefined;
})();
if (typeof surfaceOverride === "number" && surfaceOverride > 0) {
return surfaceOverride;
if (typeof providerOverride === "number" && providerOverride > 0) {
return providerOverride;
}
if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface];
if (provider) return DEFAULT_CHUNK_LIMIT_BY_PROVIDER[provider];
return 4000;
}

View File

@@ -3,7 +3,7 @@ import { normalizeE164 } from "../utils.js";
import type { MsgContext } from "./templating.js";
export type CommandAuthorization = {
isWhatsAppSurface: boolean;
isWhatsAppProvider: boolean;
ownerList: string[];
senderE164?: string;
isAuthorizedSender: boolean;
@@ -17,7 +17,7 @@ export function resolveCommandAuthorization(params: {
commandAuthorized: boolean;
}): CommandAuthorization {
const { ctx, cfg, commandAuthorized } = params;
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const provider = (ctx.Provider ?? "").trim().toLowerCase();
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
const hasWhatsappPrefix =
@@ -26,30 +26,30 @@ export function resolveCommandAuthorization(params: {
const looksLikeE164 = (value: string) =>
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
const inferWhatsApp =
!surface &&
!provider &&
Boolean(cfg.whatsapp?.allowFrom?.length) &&
(looksLikeE164(from) || looksLikeE164(to));
const isWhatsAppSurface =
surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
const isWhatsAppProvider =
provider === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
const configuredAllowFrom = isWhatsAppSurface
const configuredAllowFrom = isWhatsAppProvider
? cfg.whatsapp?.allowFrom
: undefined;
const allowFromList =
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
const allowAll =
!isWhatsAppSurface ||
!isWhatsAppProvider ||
allowFromList.length === 0 ||
allowFromList.some((entry) => entry.trim() === "*");
const senderE164 = normalizeE164(
ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""),
ctx.SenderE164 ?? (isWhatsAppProvider ? from : ""),
);
const ownerCandidates =
isWhatsAppSurface && !allowAll
isWhatsAppProvider && !allowAll
? allowFromList.filter((entry) => entry !== "*")
: [];
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
if (isWhatsAppProvider && !allowAll && ownerCandidates.length === 0 && to) {
ownerCandidates.push(to);
}
const ownerList = ownerCandidates
@@ -57,14 +57,14 @@ export function resolveCommandAuthorization(params: {
.filter((entry): entry is string => Boolean(entry));
const isOwner =
!isWhatsAppSurface ||
!isWhatsAppProvider ||
allowAll ||
ownerList.length === 0 ||
(senderE164 ? ownerList.includes(senderE164) : false);
const isAuthorizedSender = commandAuthorized && isOwner;
return {
isWhatsAppSurface,
isWhatsAppProvider,
ownerList,
senderE164: senderE164 || undefined,
isAuthorizedSender,

View File

@@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest";
import { formatAgentEnvelope } from "./envelope.js";
describe("formatAgentEnvelope", () => {
it("includes surface, from, ip, host, and timestamp", () => {
it("includes provider, from, ip, host, and timestamp", () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({
surface: "WebChat",
provider: "WebChat",
from: "user1",
host: "mac-mini",
ip: "10.0.0.5",
@@ -30,7 +30,7 @@ describe("formatAgentEnvelope", () => {
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({
surface: "WebChat",
provider: "WebChat",
timestamp: ts,
body: "hello",
});
@@ -41,7 +41,7 @@ describe("formatAgentEnvelope", () => {
});
it("handles missing optional fields", () => {
const body = formatAgentEnvelope({ surface: "Telegram", body: "hi" });
const body = formatAgentEnvelope({ provider: "Telegram", body: "hi" });
expect(body).toBe("[Telegram] hi");
});
});

View File

@@ -1,5 +1,5 @@
export type AgentEnvelopeParams = {
surface: string;
provider: string;
from?: string;
timestamp?: number | Date;
host?: string;
@@ -24,8 +24,8 @@ function formatTimestamp(ts?: number | Date): string | undefined {
}
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const surface = params.surface?.trim() || "Surface";
const parts: string[] = [surface];
const provider = params.provider?.trim() || "Provider";
const parts: string[] = [provider];
if (params.from?.trim()) parts.push(params.from.trim());
if (params.host?.trim()) parts.push(params.host.trim());
if (params.ip?.trim()) parts.push(params.ip.trim());

View File

@@ -78,7 +78,7 @@ describe("block streaming", () => {
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
Surface: "discord",
Provider: "discord",
},
{
onReplyStart,
@@ -124,7 +124,7 @@ describe("block streaming", () => {
From: "+1004",
To: "+2000",
MessageSid: "msg-124",
Surface: "discord",
Provider: "discord",
},
{
onBlockReply,

View File

@@ -321,7 +321,7 @@ describe("directive parsing", () => {
Body: "/elevated maybe",
From: "+1222",
To: "+1222",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+1222",
},
{},
@@ -709,7 +709,7 @@ describe("directive parsing", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Model set to openai/gpt-4.1-mini");
const store = loadSessionStore(storePath);
const entry = store.main;
const entry = store["agent:main:main"];
expect(entry.modelOverride).toBe("gpt-4.1-mini");
expect(entry.providerOverride).toBe("openai");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -741,7 +741,7 @@ describe("directive parsing", () => {
expect(text).toContain("Model set to Opus");
expect(text).toContain("anthropic/claude-opus-4-5");
const store = loadSessionStore(storePath);
const entry = store.main;
const entry = store["agent:main:main"];
expect(entry.modelOverride).toBe("claude-opus-4-5");
expect(entry.providerOverride).toBe("anthropic");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -791,7 +791,7 @@ describe("directive parsing", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Auth profile set to anthropic:work");
const store = loadSessionStore(storePath);
const entry = store.main;
const entry = store["agent:main:main"];
expect(entry.authProfileOverride).toBe("anthropic:work");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -932,7 +932,7 @@ describe("directive parsing", () => {
Body: "hello",
From: "+1004",
To: "+2000",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+1004",
},
{},

View File

@@ -82,7 +82,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
const onReplyStart = vi.fn();
await getReplyFromConfig(
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
{ Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
{ onReplyStart, isHeartbeat: false },
makeCfg(home),
);
@@ -100,7 +100,7 @@ describe("getReplyFromConfig typing (heartbeat)", () => {
const onReplyStart = vi.fn();
await getReplyFromConfig(
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
{ Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" },
{ onReplyStart, isHeartbeat: true },
makeCfg(home),
);

View File

@@ -23,6 +23,8 @@ import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
const MAIN_SESSION_KEY = "agent:main:main";
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
@@ -166,7 +168,7 @@ describe("trigger handling", () => {
Body: "/send off",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
@@ -180,7 +182,7 @@ describe("trigger handling", () => {
string,
{ sendPolicy?: string }
>;
expect(store.main?.sendPolicy).toBe("deny");
expect(store[MAIN_SESSION_KEY]?.sendPolicy).toBe("deny");
});
});
@@ -205,7 +207,7 @@ describe("trigger handling", () => {
Body: "/elevated on",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
@@ -219,7 +221,7 @@ describe("trigger handling", () => {
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBe("on");
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
@@ -245,7 +247,7 @@ describe("trigger handling", () => {
Body: "/elevated on",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+1000",
},
{},
@@ -259,7 +261,7 @@ describe("trigger handling", () => {
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBeUndefined();
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBeUndefined();
});
});
@@ -284,7 +286,7 @@ describe("trigger handling", () => {
Body: "please /elevated on now",
From: "+2000",
To: "+2000",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+2000",
},
{},
@@ -316,7 +318,7 @@ describe("trigger handling", () => {
Body: "/elevated on",
From: "discord:123",
To: "user:123",
Surface: "discord",
Provider: "discord",
SenderName: "Peter Steinberger",
SenderUsername: "steipete",
SenderTag: "steipete",
@@ -332,7 +334,7 @@ describe("trigger handling", () => {
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBe("on");
expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
});
});
@@ -359,7 +361,7 @@ describe("trigger handling", () => {
Body: "/elevated on",
From: "discord:123",
To: "user:123",
Surface: "discord",
Provider: "discord",
SenderName: "steipete",
},
{},
@@ -510,7 +512,7 @@ describe("trigger handling", () => {
From: "123@g.us",
To: "+2000",
ChatType: "group",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+2000",
},
{},
@@ -521,7 +523,9 @@ describe("trigger handling", () => {
const store = JSON.parse(
await fs.readFile(cfg.session.store, "utf-8"),
) as Record<string, { groupActivation?: string }>;
expect(store["whatsapp:group:123@g.us"]?.groupActivation).toBe("always");
expect(store["agent:main:whatsapp:group:123@g.us"]?.groupActivation).toBe(
"always",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -535,7 +539,7 @@ describe("trigger handling", () => {
From: "123@g.us",
To: "+2000",
ChatType: "group",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+999",
},
{},
@@ -563,7 +567,7 @@ describe("trigger handling", () => {
From: "123@g.us",
To: "+2000",
ChatType: "group",
Surface: "whatsapp",
Provider: "whatsapp",
SenderE164: "+2000",
GroupSubject: "Test Group",
GroupMembers: "Alice (+1), Bob (+2)",
@@ -879,7 +883,7 @@ describe("trigger handling", () => {
From: "group:whatsapp:demo",
To: "+2000",
ChatType: "group" as const,
Surface: "whatsapp" as const,
Provider: "whatsapp" as const,
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
@@ -942,7 +946,7 @@ describe("group intro prompts", () => {
ChatType: "group",
GroupSubject: "Release Squad",
GroupMembers: "Alice, Bob",
Surface: "discord",
Provider: "discord",
},
{},
makeCfg(home),
@@ -975,7 +979,7 @@ describe("group intro prompts", () => {
To: "+1999",
ChatType: "group",
GroupSubject: "Ops",
Surface: "whatsapp",
Provider: "whatsapp",
},
{},
makeCfg(home),
@@ -1008,7 +1012,7 @@ describe("group intro prompts", () => {
To: "+1777",
ChatType: "group",
GroupSubject: "Dev Chat",
Surface: "telegram",
Provider: "telegram",
},
{},
makeCfg(home),

View File

@@ -2,7 +2,11 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
resolveAgentDir,
resolveAgentIdFromSessionKey,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { resolveModelRefFromString } from "../agents/model-selection.js";
import {
abortEmbeddedPiRun,
@@ -108,10 +112,10 @@ function stripSenderPrefix(value?: string) {
function resolveElevatedAllowList(
allowFrom: AgentElevatedAllowFromConfig | undefined,
surface: string,
provider: string,
discordFallback?: Array<string | number>,
): Array<string | number> | undefined {
switch (surface) {
switch (provider) {
case "whatsapp":
return allowFrom?.whatsapp;
case "telegram":
@@ -135,14 +139,14 @@ function resolveElevatedAllowList(
}
function isApprovedElevatedSender(params: {
surface: string;
provider: string;
ctx: MsgContext;
allowFrom?: AgentElevatedAllowFromConfig;
discordFallback?: Array<string | number>;
}): boolean {
const rawAllow = resolveElevatedAllowList(
params.allowFrom,
params.surface,
params.provider,
params.discordFallback,
);
if (!rawAllow || rawAllow.length === 0) return false;
@@ -216,12 +220,15 @@ export async function getReplyFromConfig(
}
}
const workspaceDirRaw = cfg.agent?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR;
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const workspaceDirRaw =
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
});
const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId);
const timeoutMs = resolveAgentTimeoutMs({ cfg });
const configuredTypingSeconds =
agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds;
@@ -289,20 +296,20 @@ export async function getReplyFromConfig(
sessionCtx.Body = parsedDirectives.cleaned;
sessionCtx.BodyStripped = parsedDirectives.cleaned;
const surfaceKey =
sessionCtx.Surface?.trim().toLowerCase() ??
ctx.Surface?.trim().toLowerCase() ??
const messageProviderKey =
sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ??
"";
const elevatedConfig = agentCfg?.elevated;
const discordElevatedFallback =
surfaceKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false;
const elevatedAllowed =
elevatedEnabled &&
Boolean(
surfaceKey &&
messageProviderKey &&
isApprovedElevatedSender({
surface: surfaceKey,
provider: messageProviderKey,
ctx,
allowFrom: elevatedConfig?.allowFrom,
discordFallback: discordElevatedFallback,
@@ -345,7 +352,7 @@ export async function getReplyFromConfig(
: "text_end";
const blockStreamingEnabled = resolvedBlockStreaming === "on";
const blockReplyChunking = blockStreamingEnabled
? resolveBlockStreamingChunking(cfg, sessionCtx.Surface)
? resolveBlockStreamingChunking(cfg, sessionCtx.Provider)
: undefined;
const modelState = await createModelSelectionState({
@@ -463,7 +470,7 @@ export async function getReplyFromConfig(
});
const isEmptyConfig = Object.keys(cfg).length === 0;
if (
command.isWhatsAppSurface &&
command.isWhatsAppProvider &&
isEmptyConfig &&
command.from &&
command.to &&
@@ -638,7 +645,7 @@ export async function getReplyFromConfig(
: queueBodyBase;
const resolvedQueue = resolveQueueSettings({
cfg,
surface: sessionCtx.Surface,
provider: sessionCtx.Provider,
sessionEntry,
inlineMode: perMessageQueueMode,
inlineOptions: perMessageQueueOptions,
@@ -669,9 +676,11 @@ export async function getReplyFromConfig(
summaryLine: baseBodyTrimmedRaw,
enqueuedAt: Date.now(),
run: {
agentId,
agentDir,
sessionId: sessionIdFinal,
sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
sessionFile,
workspaceDir,
config: cfg,

View File

@@ -71,7 +71,7 @@ function createMinimalRun(params?: {
const typing = createTyping();
const opts = params?.opts;
const sessionCtx = {
Surface: "whatsapp",
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
@@ -83,7 +83,7 @@ function createMinimalRun(params?: {
run: {
sessionId: "session",
sessionKey,
surface: "whatsapp",
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},

View File

@@ -186,9 +186,11 @@ export async function runReplyAgent(params: {
runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId,
sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
messageProvider:
sessionCtx.Provider?.trim().toLowerCase() || undefined,
sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir,
agentDir: followupRun.run.agentDir,
config: followupRun.run.config,
skillsSnapshot: followupRun.run.skillsSnapshot,
prompt: commandBody,

View File

@@ -1,10 +1,10 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js";
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200;
const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
const BLOCK_CHUNK_PROVIDERS = new Set<TextChunkProvider>([
"whatsapp",
"telegram",
"discord",
@@ -14,24 +14,26 @@ const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
"webchat",
]);
function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined {
if (!surface) return undefined;
const cleaned = surface.trim().toLowerCase();
return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface)
? (cleaned as TextChunkSurface)
function normalizeChunkProvider(
provider?: string,
): TextChunkProvider | undefined {
if (!provider) return undefined;
const cleaned = provider.trim().toLowerCase();
return BLOCK_CHUNK_PROVIDERS.has(cleaned as TextChunkProvider)
? (cleaned as TextChunkProvider)
: undefined;
}
export function resolveBlockStreamingChunking(
cfg: ClawdbotConfig | undefined,
surface?: string,
provider?: string,
): {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
} {
const surfaceKey = normalizeChunkSurface(surface);
const textLimit = resolveTextChunkLimit(cfg, surfaceKey);
const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk;
const maxRequested = Math.max(
1,

View File

@@ -47,8 +47,8 @@ import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { incrementCompactionCount } from "./session-updates.js";
export type CommandContext = {
surface: string;
isWhatsAppSurface: boolean;
provider: string;
isWhatsAppProvider: boolean;
ownerList: string[];
isAuthorizedSender: boolean;
senderE164?: string;
@@ -123,7 +123,7 @@ export function buildCommandContext(params: {
cfg,
commandAuthorized: params.commandAuthorized,
});
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const provider = (ctx.Provider ?? "").trim().toLowerCase();
const abortKey =
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
@@ -132,8 +132,8 @@ export function buildCommandContext(params: {
: rawBodyNormalized;
return {
surface,
isWhatsAppSurface: auth.isWhatsAppSurface,
provider,
isWhatsAppProvider: auth.isWhatsAppProvider,
ownerList: auth.ownerList,
isAuthorizedSender: auth.isAuthorizedSender,
senderE164: auth.senderE164,
@@ -220,14 +220,14 @@ export async function handleCommands(params: {
? normalizeE164(command.senderE164)
: "";
const isActivationOwner =
!command.isWhatsAppSurface || activationOwnerList.length === 0
!command.isWhatsAppProvider || activationOwnerList.length === 0
? command.isAuthorizedSender
: Boolean(activationSenderE164) &&
activationOwnerList.includes(activationSenderE164);
if (
!command.isAuthorizedSender ||
(command.isWhatsAppSurface && !isActivationOwner)
(command.isWhatsAppProvider && !isActivationOwner)
) {
logVerbose(
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
@@ -402,7 +402,7 @@ export async function handleCommands(params: {
const result = await compactEmbeddedPiSession({
sessionId,
sessionKey,
surface: command.surface,
messageProvider: command.provider,
sessionFile: resolveSessionTranscriptPath(sessionId),
workspaceDir,
config: cfg,
@@ -469,7 +469,7 @@ export async function handleCommands(params: {
cfg,
entry: sessionEntry,
sessionKey,
surface: sessionEntry?.surface ?? command.surface,
provider: sessionEntry?.provider ?? command.provider,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {

View File

@@ -90,7 +90,7 @@ describe("createFollowupRunner compaction", () => {
run: {
sessionId: "session",
sessionKey: "main",
surface: "whatsapp",
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},

View File

@@ -76,7 +76,7 @@ export function createFollowupRunner(params: {
runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
surface: queued.run.surface,
messageProvider: queued.run.messageProvider,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,

View File

@@ -19,13 +19,13 @@ describe("resolveGroupRequireMention", () => {
},
};
const ctx: TemplateContext = {
Surface: "discord",
Provider: "discord",
From: "group:123",
GroupRoom: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
surface: "discord",
provider: "discord",
id: "123",
chatType: "group",
};
@@ -44,12 +44,12 @@ describe("resolveGroupRequireMention", () => {
},
};
const ctx: TemplateContext = {
Surface: "slack",
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
surface: "slack",
provider: "slack",
id: "C123",
chatType: "group",
};

View File

@@ -50,22 +50,23 @@ export function resolveGroupRequireMention(params: {
groupResolution?: GroupKeyResolution;
}): boolean {
const { cfg, ctx, groupResolution } = params;
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
const provider =
groupResolution?.provider ?? ctx.Provider?.trim().toLowerCase();
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim();
const groupSpace = ctx.GroupSpace?.trim();
if (
surface === "telegram" ||
surface === "whatsapp" ||
surface === "imessage"
provider === "telegram" ||
provider === "whatsapp" ||
provider === "imessage"
) {
return resolveProviderGroupRequireMention({
cfg,
surface,
provider,
groupId,
});
}
if (surface === "discord") {
if (provider === "discord") {
const guildEntry = resolveDiscordGuildEntry(
cfg.discord?.guilds,
groupSpace,
@@ -90,7 +91,7 @@ export function resolveGroupRequireMention(params: {
}
return true;
}
if (surface === "slack") {
if (provider === "slack") {
const channels = cfg.slack?.channels ?? {};
const keys = Object.keys(channels);
if (keys.length === 0) return true;
@@ -137,18 +138,18 @@ export function buildGroupIntro(params: {
params.defaultActivation;
const subject = params.sessionCtx.GroupSubject?.trim();
const members = params.sessionCtx.GroupMembers?.trim();
const surface = params.sessionCtx.Surface?.trim().toLowerCase();
const surfaceLabel = (() => {
if (!surface) return "chat";
if (surface === "whatsapp") return "WhatsApp";
if (surface === "telegram") return "Telegram";
if (surface === "discord") return "Discord";
if (surface === "webchat") return "WebChat";
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`;
const provider = params.sessionCtx.Provider?.trim().toLowerCase();
const providerLabel = (() => {
if (!provider) return "chat";
if (provider === "whatsapp") return "WhatsApp";
if (provider === "telegram") return "Telegram";
if (provider === "discord") return "Discord";
if (provider === "webchat") return "WebChat";
return `${provider.at(0)?.toUpperCase() ?? ""}${provider.slice(1)}`;
})();
const subjectLine = subject
? `You are replying inside the ${surfaceLabel} group "${subject}".`
: `You are replying inside a ${surfaceLabel} group chat.`;
? `You are replying inside the ${providerLabel} group "${subject}".`
: `You are replying inside a ${providerLabel} group chat.`;
const membersLine = members ? `Group members: ${members}.` : undefined;
const activationLine =
activation === "always"

View File

@@ -23,9 +23,11 @@ export type FollowupRun = {
summaryLine?: string;
enqueuedAt: number;
run: {
agentId: string;
agentDir: string;
sessionId: string;
sessionKey?: string;
surface?: string;
messageProvider?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdbotConfig;
@@ -425,8 +427,8 @@ export function scheduleFollowupDrain(
}
})();
}
function defaultQueueModeForSurface(surface?: string): QueueMode {
const normalized = surface?.trim().toLowerCase();
function defaultQueueModeForProvider(provider?: string): QueueMode {
const normalized = provider?.trim().toLowerCase();
if (normalized === "discord") return "collect";
if (normalized === "webchat") return "collect";
if (normalized === "whatsapp") return "collect";
@@ -437,23 +439,23 @@ function defaultQueueModeForSurface(surface?: string): QueueMode {
}
export function resolveQueueSettings(params: {
cfg: ClawdbotConfig;
surface?: string;
provider?: string;
sessionEntry?: SessionEntry;
inlineMode?: QueueMode;
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
const surfaceKey = params.surface?.trim().toLowerCase();
const providerKey = params.provider?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue;
const surfaceModeRaw =
surfaceKey && queueCfg?.bySurface
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
const providerModeRaw =
providerKey && queueCfg?.byProvider
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
: undefined;
const resolvedMode =
params.inlineMode ??
normalizeQueueMode(params.sessionEntry?.queueMode) ??
normalizeQueueMode(surfaceModeRaw) ??
normalizeQueueMode(providerModeRaw) ??
normalizeQueueMode(queueCfg?.mode) ??
defaultQueueModeForSurface(surfaceKey);
defaultQueueModeForProvider(providerKey);
const debounceRaw =
params.inlineOptions?.debounceMs ??
params.sessionEntry?.queueDebounceMs ??

View File

@@ -7,6 +7,7 @@ import {
DEFAULT_RESET_TRIGGERS,
type GroupKeyResolution,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveGroupSessionKey,
resolveSessionKey,
resolveStorePath,
@@ -43,6 +44,7 @@ export async function initSessionState(params: {
const { ctx, cfg, commandAuthorized } = params;
const sessionCfg = cfg.session;
const mainKey = sessionCfg?.mainKey ?? "main";
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
@@ -51,12 +53,12 @@ export async function initSessionState(params: {
1,
);
const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store);
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const sessionStore: Record<string, SessionEntry> =
loadSessionStore(storePath);
let sessionKey: string | undefined;
let sessionEntry: SessionEntry | undefined;
let sessionEntry: SessionEntry;
let sessionId: string | undefined;
let isNewSession = false;
@@ -154,30 +156,30 @@ export async function initSessionState(params: {
queueDrop: baseEntry?.queueDrop,
displayName: baseEntry?.displayName,
chatType: baseEntry?.chatType,
surface: baseEntry?.surface,
provider: baseEntry?.provider,
subject: baseEntry?.subject,
room: baseEntry?.room,
space: baseEntry?.space,
};
if (groupResolution?.surface) {
const surface = groupResolution.surface;
if (groupResolution?.provider) {
const provider = groupResolution.provider;
const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const isRoomSurface = surface === "discord" || surface === "slack";
const isRoomProvider = provider === "discord" || provider === "slack";
const nextRoom =
explicitRoom ??
(isRoomSurface && subject && subject.startsWith("#")
(isRoomProvider && subject && subject.startsWith("#")
? subject
: undefined);
const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.surface = surface;
sessionEntry.provider = provider;
if (nextSubject) sessionEntry.subject = nextSubject;
if (nextRoom) sessionEntry.room = nextRoom;
if (space) sessionEntry.space = space;
sessionEntry.displayName = buildGroupDisplayName({
surface: sessionEntry.surface,
provider: sessionEntry.provider,
subject: sessionEntry.subject,
room: sessionEntry.room,
space: sessionEntry.space,

View File

@@ -24,7 +24,7 @@ describe("buildStatusMessage", () => {
verboseLevel: "on",
compactionCount: 2,
},
sessionKey: "main",
sessionKey: "agent:main:main",
sessionScope: "per-sender",
storePath: "/tmp/sessions.json",
resolvedThink: "medium",
@@ -39,7 +39,7 @@ describe("buildStatusMessage", () => {
expect(text).toContain("Agent: embedded pi");
expect(text).toContain("Runtime: direct");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("Session: main");
expect(text).toContain("Session: agent:main:main");
expect(text).toContain("compactions 2");
expect(text).toContain("Web: linked");
expect(text).toContain("heartbeat 45s");
@@ -70,7 +70,7 @@ describe("buildStatusMessage", () => {
groupActivation: "always",
chatType: "group",
},
sessionKey: "whatsapp:group:123@g.us",
sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender",
webLinked: true,
});
@@ -91,6 +91,8 @@ describe("buildStatusMessage", () => {
const storePath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
"sessions.json",
);
@@ -98,6 +100,8 @@ describe("buildStatusMessage", () => {
const logPath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
`${sessionId}.jsonl`,
);
@@ -135,7 +139,7 @@ describe("buildStatusMessage", () => {
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "main",
sessionKey: "agent:main:main",
sessionScope: "per-sender",
storePath,
webLinked: true,

View File

@@ -3,6 +3,8 @@ export type MsgContext = {
From?: string;
To?: string;
SessionKey?: string;
/** Provider account id (multi-account). */
AccountId?: string;
MessageSid?: string;
ReplyToId?: string;
ReplyToBody?: string;
@@ -24,7 +26,8 @@ export type MsgContext = {
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Surface?: string;
/** Provider label (whatsapp|telegram|discord|imessage|...). */
Provider?: string;
WasMentioned?: boolean;
CommandAuthorized?: boolean;
};