Skip to content

Commit af73d0c

Browse files
improvement(file-search): publish complete indexes in bounded text chunks (#7947)
* improvement(file-search): publish complete indexes in bounded text chunks * fix(file-search): address compatibility and extraction review findings * fix(file-search): preserve spreadsheet extraction cancellation * fix(file-search): align agent content reads with indexed lines * fix(file-search): cancel abandoned text reads
1 parent 1facb12 commit af73d0c

55 files changed

Lines changed: 31006 additions & 1002 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/integrations/file.mdx‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Read workspace file objects from selected files, canonical workspace file IDs, o
5555

5656
### File Get Content
5757

58-
Extract the text content of workspace files selected directly, identified by canonical file ID, or collected from one or more workspace folders.
58+
Extract workspace file text using the same parser mode as File Search. Use the returned fileId and offset/limit to read the matching line and surrounding context. For documents and spreadsheets, line numbers refer to extracted text, not page numbers or worksheet row numbers.
5959

6060
#### Input
6161

@@ -77,7 +77,7 @@ Extract the text content of workspace files selected directly, identified by can
7777

7878
### File Search
7979

80-
Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped or partial files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees.
80+
Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees.
8181

8282
#### Input
8383

@@ -99,13 +99,13 @@ Search the indexed text of active workspace files for lines matching a query, an
9999
| ↳ `text` | string | Matching line or bounded match-centered preview. |
100100
| `count` | number | Number of returned matching lines. |
101101
| `truncated` | boolean | Whether more matching lines exist beyond the configured hard cap. |
102-
| `complete` | boolean | Whether indexing has no pending or failed current revisions; skipped and partial coverage is reported separately. |
102+
| `complete` | boolean | Whether indexing has no pending or failed current revisions; excluded files are reported separately. |
103103
| `indexStatus` | object | Current workspace search-index coverage by file status. |
104-
| ↳ `readyFiles` | number | Files whose current revision is searchable. |
104+
| ↳ `readyFiles` | number | Files whose entire current extracted text is searchable. |
105105
| ↳ `pendingFiles` | number | Files still waiting to be indexed. |
106106
| ↳ `failedFiles` | number | Files whose current indexing attempt failed. |
107-
| ↳ `skippedFiles` | number | Files intentionally excluded because they are unsupported or oversized. |
108-
| ↳ `partialFiles` | number | Searchable files whose extracted text was truncated by the parser or cap. |
107+
| ↳ `skippedFiles` | number | Files excluded in full because they are oversized, unsupported, or cannot be completely extracted. |
108+
| ↳ `partialFiles` | number | Always zero; retained for compatibility. Files are never partially indexed. |
109109

110110
### File Fetch
111111

@@ -380,4 +380,16 @@ Move an existing workspace file into a folder. Moves the file itself; use Move F
380380
| `fileId` | string | The file that was moved. |
381381
| `folderPath` | string | The folder the file now lives in. |
382382

383+
{/* MANUAL-CONTENT-START:search_limits */}
384+
## Search coverage and limits
383385

386+
Search indexes the complete extracted text of each eligible file. The source file and its extracted UTF-8 text must each be at most **25 MiB (26,214,400 bytes)**. Oversized files, unsupported binary formats, and documents that cannot be completely extracted within parser safety limits are excluded as whole files and counted in `skippedFiles`. Search never indexes only the first rows, lines, or characters. CSV search preserves decoded source text; spreadsheet search includes populated cells beyond the preview limits. Image-only documents require searchable text; search does not perform OCR.
387+
388+
Existing parser safeguards also apply to complete extraction. PDFs allow at most 10,000 pages, 20 MiB of extracted text, 250,000 characters on one page, and 60 seconds of extraction. Office archives allow at most 150 MiB expanded in total, 64 MiB for one archive entry, and 10,000 entries; malformed archives and excessive compression ratios are rejected. Hitting any of these limits excludes the whole file from search.
389+
390+
Updates are indexed asynchronously. `pendingFiles` and `failedFiles` indicate revisions that are not yet searchable; a new revision becomes searchable only when its full index is ready. An empty result proves absence only within the searched scope when `complete` is true and `skippedFiles` is zero.
391+
392+
Regex is evaluated against complete logical lines, including long lines, and cannot span line breaks. Returned lines may use a shortened preview. The result limit (up to 200 lines) and a 10-second query deadline limit an individual request, not the amount of text indexed. An expensive query fails explicitly instead of returning an apparently complete subset; narrow its literal text or folder scope and retry.
393+
394+
Search returns one result per matching logical line with `fileId`, 1-based `lineNumber`, and `text` (a bounded preview for long lines). An Agent can use **Search** to locate content, then **Get Content** with the returned `fileId`, `offset` near `lineNumber`, and a small `limit` to read surrounding context. These operations use the same complete-text parser mode. Line numbers for documents and spreadsheets refer to extracted text, not page numbers or worksheet row numbers. Re-run search if the file changes between calls.
395+
{/* MANUAL-CONTENT-END */}

‎apps/sim/lib/copilot/tools/server/files/doc-compile.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { sha256Hex } from '@sim/security/hash'
44
import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error'
55
import {
6+
type CompiledDocReadOptions,
67
loadCompiledDoc,
78
loadPublishedCompiledDoc,
89
publishCompiledDocArtifact,
@@ -736,26 +737,32 @@ export async function loadCompiledDocByExt(
736737
workspaceId: string,
737738
source: string,
738739
ext: string,
739-
options: {
740+
options: CompiledDocReadOptions & {
740741
allowLegacyReferencedArtifact?: boolean
741742
allowPublishedReferencedArtifact?: boolean
742743
filePrincipal?: Principal
743744
} = {}
744745
): Promise<{ buffer: Buffer; contentType: string } | null> {
745746
const fmt = await getE2BDocFormat(`x.${ext}`)
746747
if (!fmt) return null
748+
const readOptions: CompiledDocReadOptions = { maxBytes: options.maxBytes, signal: options.signal }
747749
const referencedFileIds = collectReferencedFileIds(source)
748750
if (!options.filePrincipal) {
749751
if (referencedFileIds.size === 0) {
750-
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
752+
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
751753
return buffer ? { buffer, contentType: fmt.contentType } : null
752754
}
753755
if (options.allowPublishedReferencedArtifact) {
754-
const publishedBuffer = await loadPublishedCompiledDoc(workspaceId, source, fmt.ext)
756+
const publishedBuffer = await loadPublishedCompiledDoc(
757+
workspaceId,
758+
source,
759+
fmt.ext,
760+
readOptions
761+
)
755762
if (publishedBuffer) return { buffer: publishedBuffer, contentType: fmt.contentType }
756763
}
757764
if (!options.allowLegacyReferencedArtifact) return null
758-
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
765+
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
759766
return legacyBuffer ? { buffer: legacyBuffer, contentType: fmt.contentType } : null
760767
}
761768
const referencedImages = await resolveReferencedImages(
@@ -768,11 +775,12 @@ export async function loadCompiledDocByExt(
768775
workspaceId,
769776
source,
770777
fmt.ext,
771-
referencedImages.artifactIdentity
778+
referencedImages.artifactIdentity,
779+
readOptions
772780
)
773781
if (buffer) return { buffer, contentType: fmt.contentType }
774782
if (referencedImages.artifactIdentity && options.allowLegacyReferencedArtifact) {
775-
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
783+
const legacyBuffer = await loadCompiledDoc(workspaceId, source, fmt.ext, undefined, readOptions)
776784
if (legacyBuffer) return { buffer: legacyBuffer, contentType: fmt.contentType }
777785
}
778786
return null
@@ -799,7 +807,8 @@ export type ServableDoc =
799807
export async function resolveServableDoc(
800808
workspaceId: string,
801809
storedBytes: Buffer,
802-
fileName: string
810+
fileName: string,
811+
options: CompiledDocReadOptions = {}
803812
): Promise<ServableDoc> {
804813
const fmt = await getE2BDocFormat(fileName)
805814
if (!fmt) return { kind: 'passthrough' }
@@ -810,7 +819,7 @@ export async function resolveServableDoc(
810819
workspaceId,
811820
storedBytes.toString('utf-8'),
812821
fmt.ext,
813-
{ allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
822+
{ ...options, allowLegacyReferencedArtifact: true, allowPublishedReferencedArtifact: true }
814823
)
815824
return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' }
816825
} catch (error) {

‎apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,18 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockDownloadFile, mockHeadObject, mockUploadFile } = vi.hoisted(() => ({
6+
const { mockDownloadFile, mockUploadFile } = vi.hoisted(() => ({
77
mockDownloadFile: vi.fn(),
8-
mockHeadObject: vi.fn(),
98
mockUploadFile: vi.fn(),
109
}))
1110

1211
vi.mock('@/lib/uploads/core/storage-service', () => ({
1312
downloadFile: mockDownloadFile,
14-
headObject: mockHeadObject,
1513
uploadFile: mockUploadFile,
1614
}))
1715

1816
import {
17+
loadCompiledDoc,
1918
loadPublishedCompiledDoc,
2019
storeCompiledDoc,
2120
} from '@/lib/copilot/tools/server/files/doc-compiled-store'
@@ -25,7 +24,10 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
2524
describe('compiled document publication', () => {
2625
beforeEach(() => {
2726
vi.clearAllMocks()
28-
mockHeadObject.mockResolvedValue(null)
27+
mockDownloadFile.mockReset()
28+
mockDownloadFile.mockRejectedValue(
29+
Object.assign(new Error('Missing object'), { code: 'NoSuchKey' })
30+
)
2931
})
3032

3133
it('publishes a source-keyed pointer after storing a dependency-bound artifact', async () => {
@@ -53,7 +55,6 @@ describe('compiled document publication', () => {
5355
})
5456

5557
it('loads only the exact dependency-bound artifact named by the published pointer', async () => {
56-
mockHeadObject.mockResolvedValue({ size: 1 })
5758
mockDownloadFile
5859
.mockResolvedValueOnce(
5960
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
@@ -70,7 +71,6 @@ describe('compiled document publication', () => {
7071
})
7172

7273
it('bounds the artifact read so an oversized artifact is never materialized', async () => {
73-
mockHeadObject.mockResolvedValue({ size: 1 })
7474
mockDownloadFile.mockResolvedValueOnce(
7575
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
7676
)
@@ -88,7 +88,6 @@ describe('compiled document publication', () => {
8888
it('surfaces an oversized artifact instead of reporting it as not yet built', async () => {
8989
// `null` means "still compiling", which callers answer with a retry — an artifact
9090
// that is too large would sit behind that answer forever.
91-
mockHeadObject.mockResolvedValue({ size: 1 })
9291
mockDownloadFile.mockResolvedValueOnce(
9392
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
9493
)
@@ -105,8 +104,61 @@ describe('compiled document publication', () => {
105104
)
106105
})
107106

107+
it('applies the caller budget and cancellation to both pointer and artifact downloads', async () => {
108+
const signal = new AbortController().signal
109+
const maxBytes = 25 * 1024 * 1024
110+
mockDownloadFile
111+
.mockResolvedValueOnce(
112+
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'x'.repeat(8192) }))
113+
)
114+
.mockResolvedValueOnce(Buffer.from('%PDF-artifact'))
115+
116+
await loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', { maxBytes, signal })
117+
118+
expect(mockDownloadFile).toHaveBeenCalledTimes(2)
119+
for (const [options] of mockDownloadFile.mock.calls) {
120+
expect(options).toMatchObject({ maxBytes, signal })
121+
}
122+
})
123+
124+
it('cancels a pointer read without an uncancellable metadata preflight', async () => {
125+
const controller = new AbortController()
126+
mockDownloadFile.mockImplementationOnce(async ({ signal }) => {
127+
expect(signal).toBe(controller.signal)
128+
controller.abort()
129+
signal.throwIfAborted()
130+
})
131+
await expect(
132+
loadPublishedCompiledDoc('workspace-1', 'source', 'pdf', {
133+
signal: controller.signal,
134+
})
135+
).rejects.toMatchObject({ name: 'AbortError' })
136+
})
137+
it.each(['NoSuchKey', 'BlobNotFound', 'ENOENT', 404])(
138+
'returns null for a missing pointer (%s)',
139+
async (code) => {
140+
mockDownloadFile.mockRejectedValueOnce(Object.assign(new Error('Missing'), { code }))
141+
await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).resolves.toBeNull()
142+
}
143+
)
144+
it('propagates pointer permission errors', async () => {
145+
mockDownloadFile.mockRejectedValueOnce(new Error('Access denied'))
146+
await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow(
147+
'Access denied'
148+
)
149+
})
150+
it('does not turn an interrupted artifact download into a cache miss', async () => {
151+
const controller = new AbortController()
152+
mockDownloadFile.mockImplementationOnce(async () => {
153+
controller.abort()
154+
throw new Error('download interrupted')
155+
})
156+
await expect(
157+
loadCompiledDoc('workspace-1', 'source', 'pdf', undefined, { signal: controller.signal })
158+
).rejects.toMatchObject({ name: 'AbortError' })
159+
})
160+
108161
it('still reports a missing artifact as not yet built', async () => {
109-
mockHeadObject.mockResolvedValue({ size: 1 })
110162
mockDownloadFile.mockResolvedValueOnce(
111163
Buffer.from(JSON.stringify({ version: 1, referencedInputIdentity: 'dependency-identity' }))
112164
)
@@ -118,7 +170,6 @@ describe('compiled document publication', () => {
118170
})
119171

120172
it('fails fast on a malformed published pointer', async () => {
121-
mockHeadObject.mockResolvedValue({ size: 1 })
122173
mockDownloadFile.mockResolvedValueOnce(Buffer.from('{not-json'))
123174

124175
await expect(loadPublishedCompiledDoc('workspace-1', 'source', 'pdf')).rejects.toThrow(

‎apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts‎

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
44
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
5-
import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service'
5+
import { isObjectNotFoundError } from '@/lib/uploads/core/errors'
6+
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
67
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
78

89
const logger = createLogger('CopilotDocCompiledStore')
@@ -37,15 +38,37 @@ function publishedArtifactPointerKey(workspaceId: string, source: string, ext: s
3738
return `copilot-doc-compiled/${workspaceId}/${sourceHash}.${ext}.published.json`
3839
}
3940

41+
export interface CompiledDocReadOptions {
42+
maxBytes?: number
43+
signal?: AbortSignal
44+
}
45+
4046
interface PublishedArtifactPointer {
4147
version: 1
4248
referencedInputIdentity: string
4349
}
4450

45-
async function loadPublishedArtifactPointer(key: string): Promise<PublishedArtifactPointer | null> {
46-
const stored = await headObject(key, 'copilot')
47-
if (!stored) return null
48-
const encoded = await downloadFile({ key, context: 'copilot' })
51+
async function loadPublishedArtifactPointer(
52+
key: string,
53+
options: CompiledDocReadOptions = {}
54+
): Promise<PublishedArtifactPointer | null> {
55+
options.signal?.throwIfAborted()
56+
let encoded: Buffer
57+
try {
58+
encoded = await downloadFile({
59+
key,
60+
context: 'copilot',
61+
maxBytes: Math.min(
62+
options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES,
63+
MAX_BUFFERED_TRANSFER_BYTES
64+
),
65+
signal: options.signal,
66+
})
67+
} catch (error) {
68+
options.signal?.throwIfAborted()
69+
if (isObjectNotFoundError(error)) return null
70+
throw error
71+
}
4972

5073
let decoded: unknown
5174
try {
@@ -75,11 +98,8 @@ async function loadPublishedArtifactPointer(key: string): Promise<PublishedArtif
7598
* about the size of this. Bounding it here rather than on the finished response is
7699
* what keeps an oversized artifact from being materialized before it is refused.
77100
*
78-
* The bound is the WIDEST ceiling any consumer of this funnel allows, because it is a
79-
* memory backstop and not a policy: a consumer that permits less enforces its own
80-
* limit on what it got back (the workspace download path holds artifacts to
81-
* `MAX_RENDERED_DOCUMENT_BYTES`, half of this). Using the tighter figure here instead
82-
* would reject artifacts the serving routes are willing to return.
101+
* The default is the widest ceiling consumers allow. Callers can tighten it before
102+
* downloading, so indexing does not materialize an artifact it will immediately reject.
83103
*
84104
* A size breach is rethrown rather than folded into `null`: null means "not built
85105
* yet", which callers answer with "still being prepared, try again", and an artifact
@@ -89,12 +109,22 @@ export async function loadCompiledDoc(
89109
workspaceId: string,
90110
source: string,
91111
ext: string,
92-
referencedInputIdentity?: string
112+
referencedInputIdentity?: string,
113+
options: CompiledDocReadOptions = {}
93114
): Promise<Buffer | null> {
94115
const key = compiledArtifactKey(workspaceId, source, ext, referencedInputIdentity)
95116
try {
96-
return await downloadFile({ key, context: 'copilot', maxBytes: MAX_BUFFERED_TRANSFER_BYTES })
117+
return await downloadFile({
118+
key,
119+
context: 'copilot',
120+
maxBytes: Math.min(
121+
options.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES,
122+
MAX_BUFFERED_TRANSFER_BYTES
123+
),
124+
signal: options.signal,
125+
})
97126
} catch (error) {
127+
options.signal?.throwIfAborted()
98128
if (isPayloadSizeLimitError(error)) throw error
99129
return null
100130
}
@@ -140,12 +170,19 @@ export async function publishCompiledDocArtifact(
140170
export async function loadPublishedCompiledDoc(
141171
workspaceId: string,
142172
source: string,
143-
ext: string
173+
ext: string,
174+
options: CompiledDocReadOptions = {}
144175
): Promise<Buffer | null> {
145176
const key = publishedArtifactPointerKey(workspaceId, source, ext)
146-
const pointer = await loadPublishedArtifactPointer(key)
177+
const pointer = await loadPublishedArtifactPointer(key, options)
147178
if (!pointer) return null
148-
const artifact = await loadCompiledDoc(workspaceId, source, ext, pointer.referencedInputIdentity)
179+
const artifact = await loadCompiledDoc(
180+
workspaceId,
181+
source,
182+
ext,
183+
pointer.referencedInputIdentity,
184+
options
185+
)
149186
if (!artifact) throw new Error(`Published compiled document artifact is missing: ${key}`)
150187
return artifact
151188
}

0 commit comments

Comments
 (0)