feat: configurable outbound text chunk limits

This commit is contained in:
Peter Steinberger
2026-01-03 01:07:23 +01:00
parent 2d28fa34f5
commit 5684e2d658
12 changed files with 154 additions and 27 deletions

View File

@@ -55,6 +55,7 @@
### Fixes
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
- Messages: make outbound text chunk limits configurable (defaults remain 4000/Discord 2000).
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
- Agent prompt: remove hardcoded user name in system prompt example.

View File

@@ -282,7 +282,17 @@ Controls inbound/outbound prefixes and timestamps.
messages: {
messagePrefix: "[clawdis]",
responsePrefix: "🦞",
timestampPrefix: "Europe/London"
timestampPrefix: "Europe/London",
// outbound chunk size (chars); defaults vary by surface (e.g. 4000, Discord 2000)
textChunkLimit: 4000,
// optional per-surface overrides
textChunkLimitBySurface: {
whatsapp: 4000,
telegram: 4000,
signal: 4000,
imessage: 4000,
discord: 2000
}
}
}
```

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { chunkText } from "./chunk.js";
import { chunkText, resolveTextChunkLimit } from "./chunk.js";
describe("chunkText", () => {
it("keeps multi-line text in one chunk when under limit", () => {
@@ -45,3 +45,30 @@ describe("chunkText", () => {
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
});
});
describe("resolveTextChunkLimit", () => {
it("uses per-surface defaults", () => {
expect(resolveTextChunkLimit(undefined, "whatsapp")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "telegram")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "signal")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "imessage")).toBe(4000);
expect(resolveTextChunkLimit(undefined, "discord")).toBe(2000);
});
it("supports a global override", () => {
const cfg = { messages: { textChunkLimit: 1234 } };
expect(resolveTextChunkLimit(cfg, "whatsapp")).toBe(1234);
expect(resolveTextChunkLimit(cfg, "discord")).toBe(1234);
});
it("prefers per-surface overrides over global", () => {
const cfg = {
messages: {
textChunkLimit: 1234,
textChunkLimitBySurface: { discord: 111 },
},
};
expect(resolveTextChunkLimit(cfg, "discord")).toBe(111);
expect(resolveTextChunkLimit(cfg, "telegram")).toBe(1234);
});
});

View File

@@ -2,6 +2,43 @@
// unintentionally breaking on newlines. Using [\s\S] keeps newlines inside
// the chunk so messages are only split when they truly exceed the limit.
import type { ClawdisConfig } from "../config/config.js";
export type TextChunkSurface =
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage"
| "webchat";
const DEFAULT_CHUNK_LIMIT_BY_SURFACE: Record<TextChunkSurface, number> = {
whatsapp: 4000,
telegram: 4000,
discord: 2000,
signal: 4000,
imessage: 4000,
webchat: 4000,
};
export function resolveTextChunkLimit(
cfg: Pick<ClawdisConfig, "messages"> | undefined,
surface?: TextChunkSurface,
): number {
const surfaceOverride = surface
? cfg?.messages?.textChunkLimitBySurface?.[surface]
: undefined;
if (typeof surfaceOverride === "number" && surfaceOverride > 0) {
return surfaceOverride;
}
const globalOverride = cfg?.messages?.textChunkLimit;
if (typeof globalOverride === "number" && globalOverride > 0) {
return globalOverride;
}
if (surface) return DEFAULT_CHUNK_LIMIT_BY_SURFACE[surface];
return 4000;
}
export function chunkText(text: string, limit: number): string[] {
if (!text) return [];
if (limit <= 0) return [text];

View File

@@ -17,7 +17,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import type { MsgContext } from "../auto-reply/templating.js";
import {
normalizeThinkLevel,
@@ -524,6 +524,15 @@ export async function agentCommand(
return;
}
const deliveryTextLimit =
deliveryProvider === "whatsapp" ||
deliveryProvider === "telegram" ||
deliveryProvider === "discord" ||
deliveryProvider === "signal" ||
deliveryProvider === "imessage"
? resolveTextChunkLimit(cfg, deliveryProvider)
: resolveTextChunkLimit(cfg, "whatsapp");
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -564,7 +573,7 @@ export async function agentCommand(
if (deliveryProvider === "telegram" && telegramTarget) {
try {
if (media.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, deliveryTextLimit)) {
await deps.sendMessageTelegram(telegramTarget, chunk, {
verbose: false,
token: telegramToken || undefined,
@@ -645,7 +654,7 @@ export async function agentCommand(
if (deliveryProvider === "imessage" && imessageTarget) {
try {
if (media.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, deliveryTextLimit)) {
await deps.sendMessageIMessage(imessageTarget, chunk, {
maxBytes: cfg.imessage?.mediaMaxMb
? cfg.imessage.mediaMaxMb * 1024 * 1024

View File

@@ -314,6 +314,15 @@ export type MessagesConfig = {
messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "")
responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞")
timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC)
/** Outbound text chunk size (chars). Default varies by provider (e.g. 4000, Discord 2000). */
textChunkLimit?: number;
/** Optional per-surface chunk overrides. */
textChunkLimitBySurface?: Partial<
Record<
"whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "webchat",
number
>
>;
};
export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
@@ -708,6 +717,17 @@ const MessagesSchema = z
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
timestampPrefix: z.union([z.boolean(), z.string()]).optional(),
textChunkLimit: z.number().int().positive().optional(),
textChunkLimitBySurface: z
.object({
whatsapp: z.number().int().positive().optional(),
telegram: z.number().int().positive().optional(),
discord: z.number().int().positive().optional(),
signal: z.number().int().positive().optional(),
imessage: z.number().int().positive().optional(),
webchat: z.number().int().positive().optional(),
})
.optional(),
})
.optional();

View File

@@ -12,7 +12,7 @@ import {
DEFAULT_AGENT_WORKSPACE_DIR,
ensureAgentWorkspace,
} from "../agents/workspace.js";
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
import type { CliDeps } from "../cli/deps.js";
import type { ClawdisConfig } from "../config/config.js";
@@ -357,12 +357,13 @@ export async function runCronIsolatedAgentTurn(params: {
};
}
const chatId = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkText(payload.text ?? "", 4000)) {
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
await params.deps.sendMessageTelegram(chatId, chunk, {
verbose: false,
token: telegramToken || undefined,
@@ -444,12 +445,13 @@ export async function runCronIsolatedAgentTurn(params: {
};
}
const to = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "signal");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkText(payload.text ?? "", 4000)) {
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
await params.deps.sendMessageSignal(to, chunk);
}
} else {
@@ -482,12 +484,13 @@ export async function runCronIsolatedAgentTurn(params: {
};
}
const to = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "imessage");
try {
for (const payload of payloads) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
if (mediaList.length === 0) {
for (const chunk of chunkText(payload.text ?? "", 4000)) {
for (const chunk of chunkText(payload.text ?? "", textLimit)) {
await params.deps.sendMessageIMessage(to, chunk);
}
} else {

View File

@@ -1,4 +1,4 @@
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -111,15 +111,16 @@ async function deliverReplies(params: {
client: Awaited<ReturnType<typeof createIMessageRpcClient>>;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
}) {
const { replies, target, client, runtime, maxBytes } = params;
const { replies, target, client, runtime, maxBytes, textLimit } = params;
for (const payload of replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, textLimit)) {
await sendMessageIMessage(target, chunk, { maxBytes, client });
}
} else {
@@ -143,6 +144,7 @@ export async function monitorIMessageProvider(
): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "imessage");
const allowFrom = resolveAllowFrom(opts);
const mentionRegexes = resolveMentionRegexes(cfg);
const includeAttachments =
@@ -274,6 +276,7 @@ export async function monitorIMessageProvider(
client,
runtime,
maxBytes: mediaMaxBytes,
textLimit,
});
})
.catch((err) => {
@@ -302,6 +305,7 @@ export async function monitorIMessageProvider(
client,
runtime,
maxBytes: mediaMaxBytes,
textLimit,
});
};

View File

@@ -1,4 +1,4 @@
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import {
HEARTBEAT_PROMPT,
stripHeartbeatToken,
@@ -292,6 +292,7 @@ async function deliverHeartbeatReply(params: {
to: string;
text: string;
mediaUrls: string[];
textLimit: number;
deps: Required<
Pick<
HeartbeatDeps,
@@ -303,10 +304,10 @@ async function deliverHeartbeatReply(params: {
>
>;
}) {
const { channel, to, text, mediaUrls, deps } = params;
const { channel, to, text, mediaUrls, deps, textLimit } = params;
if (channel === "whatsapp") {
if (mediaUrls.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, textLimit)) {
await deps.sendWhatsApp(to, chunk, { verbose: false });
}
return;
@@ -322,7 +323,7 @@ async function deliverHeartbeatReply(params: {
if (channel === "signal") {
if (mediaUrls.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, textLimit)) {
await deps.sendSignal(to, chunk);
}
return;
@@ -338,7 +339,7 @@ async function deliverHeartbeatReply(params: {
if (channel === "imessage") {
if (mediaUrls.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, textLimit)) {
await deps.sendIMessage(to, chunk);
}
return;
@@ -354,7 +355,7 @@ async function deliverHeartbeatReply(params: {
if (channel === "telegram") {
if (mediaUrls.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, textLimit)) {
await deps.sendTelegram(to, chunk, { verbose: false });
}
return;
@@ -500,11 +501,13 @@ export async function runHeartbeatOnce(opts: {
sendSignal: opts.deps?.sendSignal ?? sendMessageSignal,
sendIMessage: opts.deps?.sendIMessage ?? sendMessageIMessage,
};
const textLimit = resolveTextChunkLimit(cfg, delivery.channel);
await deliverHeartbeatReply({
channel: delivery.channel,
to: delivery.to,
text: normalized.text,
mediaUrls,
textLimit,
deps,
});

View File

@@ -1,4 +1,4 @@
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -178,15 +178,17 @@ async function deliverReplies(params: {
account?: string;
runtime: RuntimeEnv;
maxBytes: number;
textLimit: number;
}) {
const { replies, target, baseUrl, account, runtime, maxBytes } = params;
const { replies, target, baseUrl, account, runtime, maxBytes, textLimit } =
params;
for (const payload of replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, 4000)) {
for (const chunk of chunkText(text, textLimit)) {
await sendMessageSignal(target, chunk, {
baseUrl,
account,
@@ -215,6 +217,7 @@ export async function monitorSignalProvider(
): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "signal");
const baseUrl = resolveBaseUrl(opts);
const account = resolveAccount(opts);
const allowFrom = resolveAllowFrom(opts);
@@ -391,6 +394,7 @@ export async function monitorSignalProvider(
account,
runtime,
maxBytes: mediaMaxBytes,
textLimit,
});
})
.catch((err) => {
@@ -420,6 +424,7 @@ export async function monitorSignalProvider(
account,
runtime,
maxBytes: mediaMaxBytes,
textLimit,
});
};

View File

@@ -5,7 +5,7 @@ import { apiThrottler } from "@grammyjs/transformer-throttler";
import type { ApiClientOptions, Message } from "grammy";
import { Bot, InputFile, webhookCallback } from "grammy";
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -60,6 +60,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
bot.api.config.use(apiThrottler());
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "telegram");
const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom;
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const mediaMaxBytes =
@@ -245,6 +246,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
runtime,
bot,
replyToMode,
textLimit,
});
} catch (err) {
runtime.error?.(danger(`handler failed: ${String(err)}`));
@@ -268,8 +270,9 @@ async function deliverReplies(params: {
runtime: RuntimeEnv;
bot: Bot;
replyToMode: ReplyToMode;
textLimit: number;
}) {
const { replies, chatId, runtime, bot, replyToMode } = params;
const { replies, chatId, runtime, bot, replyToMode, textLimit } = params;
let hasReplied = false;
for (const reply of replies) {
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
@@ -286,7 +289,7 @@ async function deliverReplies(params: {
? [reply.mediaUrl]
: [];
if (mediaList.length === 0) {
for (const chunk of chunkText(reply.text || "", 4000)) {
for (const chunk of chunkText(reply.text || "", textLimit)) {
await sendTelegramText(bot, chatId, chunk, runtime, {
replyToMessageId:
replyToId && (replyToMode === "all" || !hasReplied)

View File

@@ -1,4 +1,4 @@
import { chunkText } from "../auto-reply/chunk.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import {
normalizeGroupActivation,
@@ -41,7 +41,6 @@ import {
} from "./reconnect.js";
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
const WEB_TEXT_LIMIT = 4000;
const DEFAULT_GROUP_HISTORY_LIMIT = 50;
const whatsappLog = createSubsystemLogger("gateway/providers/whatsapp");
const whatsappInboundLog = whatsappLog.child("inbound");
@@ -502,6 +501,7 @@ async function deliverWebReply(params: {
replyResult: ReplyPayload;
msg: WebInboundMsg;
maxMediaBytes: number;
textLimit: number;
replyLogger: ReturnType<typeof getChildLogger>;
connectionId?: string;
skipLog?: boolean;
@@ -510,12 +510,13 @@ async function deliverWebReply(params: {
replyResult,
msg,
maxMediaBytes,
textLimit,
replyLogger,
connectionId,
skipLog,
} = params;
const replyStarted = Date.now();
const textChunks = chunkText(replyResult.text || "", WEB_TEXT_LIMIT);
const textChunks = chunkText(replyResult.text || "", textLimit);
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
@@ -1050,6 +1051,7 @@ export async function monitorWebProvider(
}
const responsePrefix = cfg.messages?.responsePrefix;
const textLimit = resolveTextChunkLimit(cfg, "whatsapp");
let didLogHeartbeatStrip = false;
let didSendReply = false;
let toolSendChain: Promise<void> = Promise.resolve();
@@ -1091,6 +1093,7 @@ export async function monitorWebProvider(
replyResult: toolPayload,
msg,
maxMediaBytes,
textLimit,
replyLogger,
connectionId,
skipLog: true,
@@ -1134,6 +1137,7 @@ export async function monitorWebProvider(
replyResult: blockPayload,
msg,
maxMediaBytes,
textLimit,
replyLogger,
connectionId,
skipLog: true,
@@ -1238,6 +1242,7 @@ export async function monitorWebProvider(
replyResult: replyPayload,
msg,
maxMediaBytes,
textLimit,
replyLogger,
connectionId,
});