Skip to content

Commit a2b830d

Browse files
committed
fix(connectors): cap assembled mail and chat text at one shared bound and advertise it as the hydration estimate
1 parent b825459 commit a2b830d

5 files changed

Lines changed: 98 additions & 45 deletions

File tree

‎apps/sim/connectors/gmail/gmail.ts‎

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/document
44
import { DEFAULT_MAX_THREADS, gmailConnectorMeta } from '@/connectors/gmail/meta'
55
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
66
import {
7+
BoundedLines,
8+
CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
79
htmlToPlainText,
810
joinTagArray,
911
parseDefaultedUnlimitedSafeInteger,
@@ -323,21 +325,17 @@ function formatThread(thread: GmailThread): {
323325
}
324326
const labelIds = [...labelIdSet]
325327

326-
const lines: string[] = []
327-
lines.push(`Subject: ${subject}`)
328-
lines.push(`From: ${from}`)
328+
const lines = new BoundedLines()
329+
lines.push(`Subject: ${subject}`, `From: ${from}`)
329330
if (to) lines.push(`To: ${to}`)
330-
lines.push(`Messages: ${messages.length}`)
331-
lines.push('')
331+
lines.push(`Messages: ${messages.length}`, '')
332332

333333
for (const msg of messages) {
334334
const msgFrom = getHeader(msg.payload, 'From') || 'Unknown'
335335
const msgDate = getHeader(msg.payload, 'Date') || ''
336336
const body = msg.payload ? extractBody(msg.payload) : ''
337337

338-
lines.push(`--- ${msgFrom} (${msgDate}) ---`)
339-
lines.push(body.trim())
340-
lines.push('')
338+
if (!lines.push(`--- ${msgFrom} (${msgDate}) ---`, body.trim(), '')) break
341339
}
342340

343341
const firstDate = firstMessage.internalDate
@@ -348,7 +346,7 @@ function formatThread(thread: GmailThread): {
348346
: undefined
349347

350348
return {
351-
content: lines.join('\n').trim(),
349+
content: lines.join().trim(),
352350
subject,
353351
metadata: {
354352
from,
@@ -398,13 +396,6 @@ async function resolveLabelNames(
398396
.filter((name) => !name.startsWith('CATEGORY_') && name !== 'UNREAD')
399397
}
400398

401-
/**
402-
* What a hydrated thread retains: message bodies as text, never attachments,
403-
* so a generous ceiling for a long thread is still small. Lets a batch of
404-
* threads hydrate together instead of one at a time.
405-
*/
406-
const ESTIMATED_THREAD_BYTES = 256 * 1024
407-
408399
/**
409400
* Creates a lightweight document stub from a thread list entry.
410401
* Uses metadata-based contentHash for change detection without downloading content.
@@ -419,7 +410,7 @@ function threadToStub(thread: {
419410
title: thread.snippet || 'Untitled Thread',
420411
content: '',
421412
contentDeferred: true,
422-
estimatedBytes: ESTIMATED_THREAD_BYTES,
413+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
423414
mimeType: 'text/plain',
424415
sourceUrl: threadUrl(thread.id),
425416
contentHash: `gmail:${thread.id}:${thread.historyId ?? ''}`,

‎apps/sim/connectors/google-chat/google-chat.ts‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,10 @@ import {
99
SPACES_PAGE_SIZE,
1010
} from '@/connectors/google-chat/meta'
1111
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
12-
import { parseTagDate } from '@/connectors/utils'
12+
import { BoundedLines, CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, parseTagDate } from '@/connectors/utils'
1313

1414
const logger = createLogger('GoogleChatConnector')
1515

16-
/** A hydrated space retains a bounded window of message text; lets spaces hydrate in batches. */
17-
const ESTIMATED_SPACE_BYTES = 1024 * 1024
18-
1916
const CHAT_API_BASE = 'https://chat.googleapis.com/v1'
2017
const MS_PER_DAY = 24 * 60 * 60 * 1000
2118

@@ -221,7 +218,7 @@ function spaceToStub(
221218
title: spaceTitle(space),
222219
content: '',
223220
contentDeferred: true,
224-
estimatedBytes: ESTIMATED_SPACE_BYTES,
221+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
225222
mimeType: 'text/plain',
226223
sourceUrl: space.spaceUri,
227224
contentHash: buildContentHash(space, maxMessages, lookbackDays, syncContext),
@@ -328,27 +325,26 @@ function senderLabel(sender: ChatUser | undefined): string {
328325
* instead, which already handles size caps, OCR, and format parsing.
329326
*/
330327
function formatSpaceContent(space: Space, messages: ChatMessage[]): string {
331-
const parts: string[] = [`Space: ${spaceTitle(space)}`]
328+
const parts = new BoundedLines()
329+
parts.push(`Space: ${spaceTitle(space)}`)
332330
const description = space.spaceDetails?.description?.trim()
333331
if (description) parts.push(`Description: ${description}`)
334332
const guidelines = space.spaceDetails?.guidelines?.trim()
335333
if (guidelines) parts.push(`Guidelines: ${guidelines}`)
336334

337-
const lines: string[] = []
335+
let headed = false
338336
for (const message of messages) {
339337
const text = message.text?.trim() || message.fallbackText?.trim()
340338
if (!text) continue
339+
if (!headed) {
340+
parts.push('', '--- Messages ---')
341+
headed = true
342+
}
341343
const timestamp = message.createTime ?? ''
342-
lines.push(`[${timestamp}] ${senderLabel(message.sender)}: ${text}`)
343-
}
344-
345-
if (lines.length > 0) {
346-
parts.push('')
347-
parts.push('--- Messages ---')
348-
parts.push(...lines)
344+
if (!parts.push(`[${timestamp}] ${senderLabel(message.sender)}: ${text}`)) break
349345
}
350346

351-
return parts.join('\n')
347+
return parts.join()
352348
}
353349

354350
/** Number of messages that actually contributed text to the transcript. */

‎apps/sim/connectors/outlook/outlook.ts‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/document
44
import { DEFAULT_MAX_CONVERSATIONS, outlookConnectorMeta } from '@/connectors/outlook/meta'
55
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
66
import {
7+
BoundedLines,
8+
CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
79
htmlToPlainText,
810
isListingScopeUnavailableError,
911
listingRequestError,
@@ -13,9 +15,6 @@ import {
1315

1416
const logger = createLogger('OutlookConnector')
1517

16-
/** A hydrated conversation retains message bodies as text, never attachments; lets threads hydrate in batches. */
17-
const ESTIMATED_CONVERSATION_BYTES = 256 * 1024
18-
1918
const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0/me'
2019
const MESSAGES_PER_PAGE = 50
2120
/**
@@ -566,24 +565,20 @@ function formatConversation(
566565
const from = formatRecipient(first.from)
567566
const to = first.toRecipients?.map(formatRecipient).join(', ') || ''
568567

569-
const lines: string[] = []
570-
lines.push(`Subject: ${subject}`)
571-
lines.push(`From: ${from}`)
568+
const lines = new BoundedLines()
569+
lines.push(`Subject: ${subject}`, `From: ${from}`)
572570
if (to) lines.push(`To: ${to}`)
573-
lines.push(`Messages: ${sorted.length}`)
574-
lines.push('')
571+
lines.push(`Messages: ${sorted.length}`, '')
575572

576573
for (const msg of sorted) {
577574
const msgFrom = formatRecipient(msg.from)
578575
const msgDate = msg.receivedDateTime || ''
579576
const body = extractBodyText(msg.body)
580577

581-
lines.push(`--- ${msgFrom} (${msgDate}) ---`)
582-
lines.push(body.trim())
583-
lines.push('')
578+
if (!lines.push(`--- ${msgFrom} (${msgDate}) ---`, body.trim(), '')) break
584579
}
585580

586-
const content = lines.join('\n').trim()
581+
const content = lines.join().trim()
587582
if (!content) return null
588583

589584
const categories = new Set<string>()
@@ -808,7 +803,7 @@ export const outlookConnector: ConnectorConfig = {
808803
title: subject,
809804
content: '',
810805
contentDeferred: true,
811-
estimatedBytes: ESTIMATED_CONVERSATION_BYTES,
806+
estimatedBytes: CONNECTOR_TEXT_DOCUMENT_MAX_BYTES,
812807
mimeType: 'text/plain',
813808
sourceUrl,
814809
contentHash: `outlook:${convId}:${lastDate}`,

‎apps/sim/connectors/utils.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { typeformConnector } from '@/connectors/typeform/typeform'
6363
import {
6464
appendPendingMicrosoftGraphFolders,
6565
assertMicrosoftGraphNextLink,
66+
BoundedLines,
6667
ConnectorFileTooLargeError,
6768
ConnectorListingScopeUnavailableError,
6869
decodeMicrosoftGraphTraversalCursor,
@@ -1639,3 +1640,26 @@ describe('isSkippableMicrosoftGraphFolderError', () => {
16391640
expect(isSkippableMicrosoftGraphFolderError(new Error('500'), perMember, false)).toBe(false)
16401641
})
16411642
})
1643+
1644+
describe('BoundedLines', () => {
1645+
it('joins everything when the text fits', () => {
1646+
const lines = new BoundedLines(64)
1647+
expect(lines.push('Subject: hi', '')).toBe(true)
1648+
expect(lines.push('--- a ---', 'body')).toBe(true)
1649+
expect(lines.join()).toBe('Subject: hi\n\n--- a ---\nbody')
1650+
})
1651+
1652+
it('refuses a record that would cross the ceiling, whole, and says so in the output', () => {
1653+
const lines = new BoundedLines(20)
1654+
expect(lines.push('first')).toBe(true)
1655+
expect(lines.push('--- header ---', 'a long body')).toBe(false)
1656+
expect(lines.push('x')).toBe(false)
1657+
expect(lines.join()).toBe('first\n[Truncated: the indexed text reached the size limit]')
1658+
})
1659+
1660+
it('counts encoded bytes, not characters', () => {
1661+
const lines = new BoundedLines(6)
1662+
expect(lines.push('éé')).toBe(true)
1663+
expect(lines.push('é')).toBe(false)
1664+
})
1665+
})

‎apps/sim/connectors/utils.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,3 +795,50 @@ export function isSkippableMicrosoftGraphFolderError(
795795
): boolean {
796796
return !isRootFolder && isListingScopeUnavailableError(error) && isPerMemberListing(syncContext)
797797
}
798+
799+
/**
800+
* Ceiling for a document a connector assembles from many source records (a
801+
* mail thread, a chat transcript) rather than downloads as one file. Formatters
802+
* enforce it through `BoundedLines`, and listings advertise it through
803+
* `ExternalDocument.estimatedBytes`, so the sync engine plans hydration around
804+
* a bound it can rely on: five such documents fit its 64 MiB in-flight budget
805+
* and hydrate together instead of one at a time.
806+
*/
807+
export const CONNECTOR_TEXT_DOCUMENT_MAX_BYTES = 12 * 1024 * 1024
808+
809+
const TRUNCATION_NOTICE = '[Truncated: the indexed text reached the size limit]'
810+
811+
/**
812+
* Accumulates newline-joined text under a byte ceiling. A record is appended
813+
* whole or not at all, so a truncated document never ends mid-message, and the
814+
* output carries a notice when something was left out.
815+
*/
816+
export class BoundedLines {
817+
private readonly lines: string[] = []
818+
private bytes = 0
819+
private truncated = false
820+
821+
constructor(private readonly maxBytes = CONNECTOR_TEXT_DOCUMENT_MAX_BYTES) {}
822+
823+
/**
824+
* Appends the lines together when they fit; otherwise marks the document
825+
* truncated, appends nothing, and returns false so the caller stops.
826+
*/
827+
push(...lines: string[]): boolean {
828+
if (this.truncated) return false
829+
let size = 0
830+
for (const line of lines) size += Buffer.byteLength(line, 'utf8') + 1
831+
if (this.bytes + size > this.maxBytes) {
832+
this.truncated = true
833+
return false
834+
}
835+
this.lines.push(...lines)
836+
this.bytes += size
837+
return true
838+
}
839+
840+
/** Joins the accepted lines, ending with the truncation notice when a push was refused. */
841+
join(): string {
842+
return this.truncated ? [...this.lines, TRUNCATION_NOTICE].join('\n') : this.lines.join('\n')
843+
}
844+
}

0 commit comments

Comments
 (0)