Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions apps/api/src/durable-objects/project-data/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): 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,
Expand All @@ -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,
};
}
Expand Down
8 changes: 6 additions & 2 deletions packages/shared/src/constants/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading