diff --git a/apps/api/src/durable-objects/project-data/messages.ts b/apps/api/src/durable-objects/project-data/messages.ts index 28ba452be..6d18b3493 100644 --- a/apps/api/src/durable-objects/project-data/messages.ts +++ b/apps/api/src/durable-objects/project-data/messages.ts @@ -258,6 +258,22 @@ export function persistMessageBatch( return { persisted, duplicates, persistedMessages, workspaceId, firstUserContent, hadTopic }; } +/** + * Cloudflare DO RPC has a hard 32 MiB serialization ceiling. + * We leave a 2 MiB margin for the session envelope, pagination metadata, + * and JSON structural overhead. + */ +const RPC_SIZE_BUDGET_BYTES = 30 * 1024 * 1024; // 30 MiB + +function estimateRowBytes(row: Record): number { + let size = 64; // object overhead + fixed fields (id, role, created_at, sequence) + const content = row.content; + if (typeof content === 'string') size += content.length * 2; // UTF-16 chars + const tm = row.tool_metadata; + if (typeof tm === 'string') size += tm.length * 2; + return size; +} + export function getMessages( sql: SqlStorage, sessionId: string, @@ -284,11 +300,37 @@ export function getMessages( params.push(limit + 1); const rows = sql.exec(query, ...params).toArray(); - const hasMore = rows.length > limit; - const messageRows = hasMore ? rows.slice(0, limit) : rows; + let hasMore = rows.length > limit; + const candidateRows = hasMore ? rows.slice(0, limit) : rows; + + // RPC size guard: walk the result set (newest-first) and stop before + // exceeding the serialization budget. Because the query returns rows in + // DESC order and we reverse() before returning, we trim from the END + // of the candidate list (i.e. the oldest messages) so the caller still + // sees the most recent messages and can paginate backwards for older ones. + let cumulativeBytes = 0; + let safeCount = candidateRows.length; + for (let i = 0; i < candidateRows.length; i++) { + const row = candidateRows[i]!; + cumulativeBytes += estimateRowBytes(row); + if (cumulativeBytes > RPC_SIZE_BUDGET_BYTES) { + safeCount = i; // exclude this row and everything after + hasMore = true; + log.warn('messages.rpc_size_guard_truncated', { + sessionId, + requestedLimit: limit, + totalRows: candidateRows.length, + truncatedTo: safeCount, + estimatedBytes: cumulativeBytes, + }); + break; + } + } + + const trimmedRows = candidateRows.slice(0, safeCount); return { - messages: messageRows.reverse().map((row) => parseChatMessageRow(row)), + messages: trimmedRows.reverse().map((row) => parseChatMessageRow(row)), hasMore, }; } diff --git a/packages/shared/src/constants/defaults.ts b/packages/shared/src/constants/defaults.ts index d4b0fbad3..e57254ce5 100644 --- a/packages/shared/src/constants/defaults.ts +++ b/packages/shared/src/constants/defaults.ts @@ -52,10 +52,14 @@ export const DEFAULT_TASK_LIST_MAX_PAGE_SIZE = 200; /** * Default message limit for chat session REST endpoints (project chat view). * Streaming-token chat messages produce many more DB rows than logical messages, - * so this limit is much higher than SAM_HISTORY_LOAD_LIMIT (200). + * so this limit is higher than SAM_HISTORY_LOAD_LIMIT (200). + * + * Kept well below the Cloudflare DO RPC serialization ceiling (32 MiB) — + * large tool-call output can easily push 3 000 rows past the limit. + * The frontend already paginates via the `before` / `hasMore` contract. * Override via CHAT_SESSION_MESSAGE_LIMIT env var. */ -export const DEFAULT_CHAT_SESSION_MESSAGE_LIMIT = 3000; +export const DEFAULT_CHAT_SESSION_MESSAGE_LIMIT = 500; /** Default callback timeout for delegated task updates in milliseconds. */ export const DEFAULT_TASK_CALLBACK_TIMEOUT_MS = 10000;