limits: chunk replies for twilio/web

This commit is contained in:
Peter Steinberger
2025-12-02 23:10:16 +00:00
parent cfaec9d608
commit 10182f1182
3 changed files with 64 additions and 31 deletions

View File

@@ -55,6 +55,7 @@
### Fixed
- Support multiple assistant text replies when using Tau RPC: agents now emit `texts` arrays and command auto-replies deliver each message separately without leaking raw JSON.
- Normalized agent parsers (pi/claude/opencode/codex/gemini) to the new plural output shape.
- Enforce outbound text size caps: WhatsApp/Twilio messages chunked at 1600 chars; web replies chunked at 4000 chars.
### Changes
- **Heartbeat backpressure:** Web reply heartbeats now check the shared command queue and skip while any command/Claude runs are in flight, preventing concurrent prompts during long-running requests.

View File

@@ -28,6 +28,8 @@ import type { GetReplyOptions, ReplyPayload } from "./types.js";
export type { GetReplyOptions, ReplyPayload } from "./types.js";
const TWILIO_TEXT_LIMIT = 1600;
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
const ABORT_MEMORY = new Map<string, boolean>();
@@ -450,37 +452,47 @@ export async function autoReplyIfConfigured(
};
for (const replyPayload of replies) {
if (replyPayload.text) {
logVerbose(
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${replyPayload.text.length}`,
);
} else {
logVerbose(
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media)`,
);
}
const mediaList = replyPayload.mediaUrls?.length
? replyPayload.mediaUrls
: replyPayload.mediaUrl
? [replyPayload.mediaUrl]
: [];
if (mediaList.length === 0) {
await sendTwilio(replyPayload.text ?? "");
} else {
await sendTwilio(replyPayload.text ?? "", mediaList[0]);
for (const extra of mediaList.slice(1)) {
await sendTwilio("", extra);
}
}
const text = replyPayload.text ?? "";
const chunks =
text.length > 0
? (text.match(new RegExp(`.{1,${TWILIO_TEXT_LIMIT}}`, "g")) ?? [])
: [""];
if (isVerbose()) {
console.log(
info(
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${replyPayload.mediaUrl ? ", media" : ""})`,
),
);
for (let i = 0; i < chunks.length; i++) {
const body = chunks[i];
const attachMedia = i === 0 ? mediaList[0] : undefined;
if (body) {
logVerbose(
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo}, body length ${body.length}`,
);
} else if (attachMedia) {
logVerbose(
`Auto-replying via Twilio: from ${replyFrom} to ${replyTo} (media only)`,
);
}
await sendTwilio(body, attachMedia);
if (i === 0 && mediaList.length > 1) {
for (const extra of mediaList.slice(1)) {
await sendTwilio("", extra);
}
}
if (isVerbose()) {
console.log(
info(
`↩️ Auto-replied to ${replyTo} (sid ${message.sid ?? "no-sid"}${attachMedia ? ", media" : ""})`,
),
);
}
}
}
} catch (err) {

View File

@@ -29,6 +29,8 @@ import {
} from "./reconnect.js";
import { getWebAuthAgeMs } from "./session.js";
const WEB_TEXT_LIMIT = 4000;
/**
* Send a message via IPC if relay is running, otherwise fall back to direct.
* This avoids Signal session corruption from multiple Baileys connections.
@@ -371,14 +373,23 @@ async function deliverWebReply(params: {
skipLog,
} = params;
const replyStarted = Date.now();
const textChunks =
(replyResult.text || "").length > 0
? ((replyResult.text || "").match(
new RegExp(`.{1,${WEB_TEXT_LIMIT}}`, "g"),
) ?? [])
: [];
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
if (mediaList.length === 0 && replyResult.text) {
await msg.reply(replyResult.text || "");
// Text-only replies
if (mediaList.length === 0 && textChunks.length) {
for (const chunk of textChunks) {
await msg.reply(chunk);
}
if (!skipLog) {
logInfo(
`✅ Sent web reply to ${msg.from} (${(Date.now() - replyStarted).toFixed(0)}ms)`,
@@ -402,7 +413,9 @@ async function deliverWebReply(params: {
return;
}
const cleanText = replyResult.text ?? undefined;
const remainingText = [...textChunks];
// Media (with optional caption on first item)
for (const [index, mediaUrl] of mediaList.entries()) {
try {
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
@@ -414,7 +427,8 @@ async function deliverWebReply(params: {
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
);
}
const caption = index === 0 ? cleanText || undefined : undefined;
const caption =
index === 0 ? remainingText.shift() || undefined : undefined;
if (media.kind === "image") {
await msg.sendMedia({
image: media.buffer,
@@ -454,7 +468,7 @@ async function deliverWebReply(params: {
connectionId: connectionId ?? null,
to: msg.from,
from: msg.to,
text: index === 0 ? (cleanText ?? null) : null,
text: caption ?? null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
mediaKind: media.kind,
@@ -466,12 +480,18 @@ async function deliverWebReply(params: {
console.error(
danger(`Failed sending web media to ${msg.from}: ${String(err)}`),
);
if (index === 0 && cleanText) {
replyLogger.warn({ err, mediaUrl }, "failed to send web media reply");
if (index === 0 && remainingText.length) {
console.log(`⚠️ Media skipped; sent text-only to ${msg.from}`);
await msg.reply(cleanText || "");
await msg.reply(remainingText.shift() || "");
}
}
}
// Remaining text chunks after media
for (const chunk of remainingText) {
await msg.reply(chunk);
}
}
export async function monitorWebProvider(