Skip to content

Commit 9a5928d

Browse files
committed
fix(file-search): align agent content reads with indexed lines
1 parent 62ec273 commit 9a5928d

13 files changed

Lines changed: 276 additions & 117 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
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

@@ -390,4 +390,6 @@ Existing parser safeguards also apply to complete extraction. PDFs allow at most
390390
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.
391391

392392
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.
393395
{/* MANUAL-CONTENT-END */}

apps/sim/lib/internal/file/operations.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
*/
44
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import * as XLSX from 'xlsx'
67
import { OrchestrationError } from '@/lib/core/orchestration/types'
8+
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
9+
import { CsvParser } from '@/lib/file-parsers/csv-parser'
10+
import { FileParserError } from '@/lib/file-parsers/errors'
11+
import { XlsxParser } from '@/lib/file-parsers/xlsx-parser'
712
import { MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths'
13+
import { extractIndexText } from '@/lib/workspace-files/search/extract'
814

915
const {
1016
mockAssertActiveWorkspaceAccess,
@@ -1064,6 +1070,78 @@ describe('file manage operations', () => {
10641070
})
10651071
})
10661072

1073+
it.each(['csv', 'xlsx'])('reads the same late %s line that search indexed', async (extension) => {
1074+
let buffer: Buffer
1075+
if (extension === 'csv') {
1076+
buffer = Buffer.from(`name,name\n${'first,second\n'.repeat(1001)}tail,needle\n`)
1077+
} else {
1078+
const workbook = XLSX.utils.book_new()
1079+
XLSX.utils.book_append_sheet(
1080+
workbook,
1081+
{
1082+
A1: { t: 's', v: 'header' },
1083+
ZZ1001: { t: 's', v: 'tail needle' },
1084+
'!ref': 'A1:ZZ1001',
1085+
},
1086+
'Data'
1087+
)
1088+
buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' })
1089+
}
1090+
const parser = extension === 'csv' ? new CsvParser() : new XlsxParser()
1091+
const parse = (
1092+
bytes: Buffer,
1093+
_extension: string,
1094+
options?: import('@/lib/file-parsers/types').FileParseOptions
1095+
) => parser.parseBuffer(bytes, options)
1096+
vi.mocked(isSupportedFileType).mockReturnValueOnce(true).mockReturnValueOnce(true)
1097+
vi.mocked(parseBuffer).mockImplementationOnce(parse).mockImplementationOnce(parse)
1098+
const indexed = await extractIndexText(
1099+
{ kind: 'stored', buffer },
1100+
`data.${extension}`,
1101+
new AbortController().signal
1102+
)
1103+
const lines = indexed!.text.split('\n')
1104+
const lineNumber = lines.findIndex((line) => line.includes('needle')) + 1
1105+
expect(lineNumber).toBeGreaterThan(0)
1106+
mockGetWorkspaceFile.mockResolvedValueOnce({
1107+
...workspaceFile('file-1'),
1108+
name: `data.${extension}`,
1109+
})
1110+
mockDownloadServableFileFromStorage.mockResolvedValueOnce({ buffer })
1111+
const response = await POST(
1112+
createMockRequest('POST', {
1113+
operation: 'content',
1114+
workspaceId: 'workspace-1',
1115+
fileId: 'file-1',
1116+
offset: lineNumber,
1117+
limit: 1,
1118+
})
1119+
)
1120+
expect(response.status).toBe(200)
1121+
expect(await response.json()).toMatchObject({
1122+
data: {
1123+
contents: [lines[lineNumber - 1]],
1124+
lineRanges: [{ offset: lineNumber, lineCount: 1, totalLinesExact: true }],
1125+
},
1126+
})
1127+
})
1128+
it('does not bypass complete extraction limits with a raw-text fallback', async () => {
1129+
vi.mocked(isSupportedFileType).mockReturnValueOnce(true)
1130+
vi.mocked(parseBuffer).mockRejectedValueOnce(
1131+
new FileParserError('complexity_limit', 'too large')
1132+
)
1133+
const response = await POST(
1134+
createMockRequest('POST', {
1135+
operation: 'content',
1136+
workspaceId: 'workspace-1',
1137+
fileId: 'file-1',
1138+
offset: 1,
1139+
limit: 1,
1140+
})
1141+
)
1142+
expect(response.status).toBe(413)
1143+
})
1144+
10671145
it('returns a scoped, deduplicated union of exact canonical file provenance', async () => {
10681146
mockGetBoundWorkspaceFileSecretProvenance.mockImplementation(
10691147
async (_workspaceId: string, identity: { fileId: string }) =>

apps/sim/lib/internal/file/operations.ts

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import {
2727
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
2828
requestsPrivateToolMetadata,
2929
} from '@/lib/execution/private-tool-metadata'
30-
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
30+
import { isSupportedFileType } from '@/lib/file-parsers'
31+
import { getFileParserErrorCode } from '@/lib/file-parsers/errors'
3132
import { buildFolderPath, parseFolderPath, ROOT_FOLDER_PATH } from '@/lib/folders/paths'
3233
import type { FolderIdScope } from '@/lib/folders/scope'
3334
import { collectFolderDepths } from '@/lib/folders/subtree'
@@ -93,14 +94,15 @@ import {
9394
} from '@/lib/workspace-files/application/workspace-file-folders'
9495
import { selectDirectoryEntries } from '@/lib/workspace-files/directory-listing'
9596
import type { WorkspaceFileContentEdit } from '@/lib/workspace-files/edit-content'
96-
import { countLines, detectLineEnding } from '@/lib/workspace-files/edit-content'
9797
import { toWorkspaceFileFolderPathView } from '@/lib/workspace-files/folder-display-path'
9898
import { resolveFolderIdsForPaths } from '@/lib/workspace-files/folder-path-selection'
9999
import {
100100
MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS,
101101
MAX_ZIP_DOWNLOAD_FILES,
102102
} from '@/lib/workspace-files/limits'
103103
import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration'
104+
import { parseWorkspaceFileText } from '@/lib/workspace-files/text-extraction'
105+
import { type FileTextLineRange, sliceFileTextLines } from '@/lib/workspace-files/text-lines'
104106
import { isWorkspaceAccessDeniedError } from '@/lib/workspaces/permissions/utils'
105107
import type { UserFile } from '@/executor/types'
106108
import {
@@ -375,48 +377,6 @@ interface ArchiveEntry {
375377

376378
const isLikelyTextBuffer = (buffer: Buffer): boolean => isUtf8(buffer) && !buffer.includes(0)
377379

378-
/** What a caller needs to tell a file that ended from a window that ran out. */
379-
interface FileContentLineRange {
380-
offset: number
381-
lineCount: number
382-
totalLines: number
383-
/** False when extraction was truncated, so `totalLines` is not the file's end. */
384-
totalLinesExact: boolean
385-
}
386-
387-
/**
388-
* Narrows extracted text to a line window.
389-
*
390-
* Reported alongside the text rather than inferred from it: without
391-
* `totalLines` a caller cannot distinguish a file that ended from a window
392-
* that stopped early, which is the same absent-versus-unknown confusion the
393-
* search index carries.
394-
*/
395-
function sliceTextLines(
396-
text: string,
397-
offset: number | undefined,
398-
limit: number | undefined,
399-
truncatedExtraction: boolean
400-
): { text: string; range?: FileContentLineRange } {
401-
if (offset === undefined && limit === undefined) return { text }
402-
403-
/* Counted the same way insert accepts them; see {@link countLines}. */
404-
const effective = text.split(/\r\n|\n/).slice(0, countLines(text))
405-
const start = Math.max((offset ?? 1) - 1, 0)
406-
const window = effective.slice(start, limit === undefined ? undefined : start + limit)
407-
408-
return {
409-
/* Rejoined with the text's own ending, so the window stays usable verbatim as an edit's search text. */
410-
text: window.join(detectLineEnding(text)),
411-
range: {
412-
offset: start + 1,
413-
lineCount: window.length,
414-
totalLines: effective.length,
415-
totalLinesExact: !truncatedExtraction,
416-
},
417-
}
418-
}
419-
420380
/**
421381
* Download a stored file and extract its text content. Parseable types (PDF, DOCX,
422382
* CSV, etc.) go through the shared file-parsers; other UTF-8 files are returned as
@@ -452,7 +412,10 @@ const extractUserFileTextContent = async (
452412
const extension = getFileExtension(userFile.name)
453413
if (extension && isSupportedFileType(extension)) {
454414
try {
455-
const result = await parseBuffer(buffer, extension)
415+
const result = await parseWorkspaceFileText(buffer, extension, {
416+
maxTextBytes: MAX_GET_CONTENT_FILE_BYTES,
417+
signal: context.signal,
418+
})
456419
if (result.metadata?.degraded === true) {
457420
/** Scraped or placeholder output is a failure, not the file's content. */
458421
throw new Error(result.metadata.warning ?? 'Parser returned degraded output')
@@ -463,6 +426,14 @@ const extractUserFileTextContent = async (
463426
contributingFiles,
464427
}
465428
} catch (error) {
429+
context.signal?.throwIfAborted()
430+
if (isPayloadSizeLimitError(error)) throw error
431+
if (getFileParserErrorCode(error) === 'complexity_limit') {
432+
throw new OrchestrationError(
433+
'payload_too_large',
434+
'File exceeds complete text extraction limits'
435+
)
436+
}
466437
logger.warn('Falling back to raw text after parser failure', {
467438
name: userFile.name,
468439
error: getErrorMessage(error, 'Unknown error'),
@@ -1191,7 +1162,7 @@ export async function executeFileManageOperation(
11911162
const provenanceSources: FileContentProvenanceSource[] = [...sources]
11921163

11931164
const contents: string[] = []
1194-
const lineRanges: FileContentLineRange[] = []
1165+
const lineRanges: FileTextLineRange[] = []
11951166
let totalBytes = 0
11961167
for (const source of sources) {
11971168
signal?.throwIfAborted()
@@ -1215,7 +1186,7 @@ export async function executeFileManageOperation(
12151186
)
12161187
for (const renderedSource of renderedSources) provenanceSources.push(renderedSource)
12171188
}
1218-
const { text: content, range } = sliceTextLines(
1189+
const { text: content, lineRange: range } = sliceFileTextLines(
12191190
extracted.text,
12201191
body.offset,
12211192
body.limit,

apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import type { Principal } from '@sim/auth/principal'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { FileParserError } from '@/lib/file-parsers/errors'
67

78
const mocks = vi.hoisted(() => ({
89
getFile: vi.fn(),
@@ -124,6 +125,29 @@ describe('readWorkspaceFileText', () => {
124125
).rejects.toMatchObject({ code: 'conflict' })
125126
})
126127

128+
it('uses the complete text representation before selecting a line window', async () => {
129+
mocks.parseBuffer.mockResolvedValueOnce({ content: 'header\ntail needle\n', metadata: {} })
130+
const result = await readWorkspaceFileText.execute({
131+
principal: principals[0],
132+
input: input({ offset: 2, limit: 1 }),
133+
})
134+
expect(mocks.parseBuffer).toHaveBeenCalledWith(expect.any(Buffer), 'txt', {
135+
contentMode: 'complete',
136+
pdfTextMode: 'complete',
137+
maxTextBytes: 25 * 1024 * 1024,
138+
signal: undefined,
139+
})
140+
expect(result.text).toBe('tail needle')
141+
expect(result.lineRange).toMatchObject({ offset: 2, lineCount: 1, totalLines: 2 })
142+
})
143+
it('reports complete extraction complexity limits as payload limits', async () => {
144+
mocks.parseBuffer.mockRejectedValueOnce(
145+
new FileParserError('complexity_limit', 'expanded text budget')
146+
)
147+
await expect(
148+
readWorkspaceFileText.execute({ principal: principals[0], input: input() })
149+
).rejects.toMatchObject({ code: 'payload_too_large' })
150+
})
127151
it('denies a principal below the read role', async () => {
128152
mocks.resolvePermission.mockResolvedValue(null)
129153

apps/sim/lib/workspace-files/application/read-workspace-file-text.ts

Lines changed: 27 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { getErrorMessage } from '@sim/utils/errors'
22
import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application'
33
import { OrchestrationError } from '@/lib/core/orchestration/types'
4-
import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers'
4+
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
5+
import { isSupportedFileType } from '@/lib/file-parsers'
6+
import { getFileParserErrorCode } from '@/lib/file-parsers/errors'
57
import {
68
type ActiveWorkspaceFileContext,
79
fetchWorkspaceFileBuffer,
@@ -18,7 +20,8 @@ import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/appl
1820
import { fileOperations } from '@/lib/workspace-files/application/operations'
1921
import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact'
2022
import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context'
21-
import { countLines, detectLineEnding } from '@/lib/workspace-files/edit-content'
23+
import { parseWorkspaceFileText } from '@/lib/workspace-files/text-extraction'
24+
import { sliceFileTextLines } from '@/lib/workspace-files/text-lines'
2225

2326
export interface ReadWorkspaceFileTextInput {
2427
fileId: string
@@ -118,7 +121,12 @@ async function executeReadWorkspaceFileText({
118121
const metadata = parsed.metadata ?? {}
119122

120123
const truncated = metadata.truncated === true
121-
const { text, lineRange } = sliceTextLines(parsed.content, input.offset, input.limit, truncated)
124+
const { text, lineRange } = sliceFileTextLines(
125+
parsed.content,
126+
input.offset,
127+
input.limit,
128+
truncated
129+
)
122130

123131
return {
124132
file,
@@ -131,63 +139,6 @@ async function executeReadWorkspaceFileText({
131139
}
132140
}
133141

134-
/**
135-
* Narrows extracted text to a line window.
136-
*
137-
* `totalLines` travels with it because without it a caller cannot tell a file
138-
* that ended from a window that stopped early, and would either stop reading
139-
* too soon or keep asking for lines that do not exist. Lines are counted the
140-
* way {@link countLines} counts them, so the numbers here name the same lines
141-
* that search reports and that an insert will accept.
142-
*
143-
* `totalLinesExact` is false when the parser stopped early: the count then
144-
* describes only the part that was extracted, and reporting it as the file's
145-
* end would tell a caller it had read everything. The separate flag is what
146-
* keeps `totalLines` useful in the ordinary case without lying in this one.
147-
*
148-
* The window is rejoined with the line ending the text already used, so a
149-
* ranged read of a CRLF file stays usable verbatim as exact search text for an edit.
150-
*/
151-
function sliceTextLines(
152-
text: string,
153-
offset: number | undefined,
154-
limit: number | undefined,
155-
truncatedExtraction: boolean
156-
): {
157-
text: string
158-
lineRange?: {
159-
offset: number
160-
lineCount: number
161-
totalLines: number
162-
totalLinesExact: boolean
163-
}
164-
} {
165-
if (offset === undefined && limit === undefined) return { text }
166-
167-
const totalLines = countLines(text)
168-
const eol = detectLineEnding(text)
169-
const lines = text.split(/\r\n|\n/).slice(0, totalLines)
170-
const start = Math.max((offset ?? 1) - 1, 0)
171-
const window = lines.slice(start, limit === undefined ? undefined : start + limit)
172-
173-
return {
174-
text: window.join(eol),
175-
lineRange: {
176-
offset: start + 1,
177-
lineCount: window.length,
178-
totalLines,
179-
totalLinesExact: !truncatedExtraction,
180-
},
181-
}
182-
}
183-
184-
/**
185-
* Extracts a workspace file's text.
186-
*
187-
* Runs on `files.read_content` unchanged: extracting text reads exactly the
188-
* bytes that operation already authorizes, and turning them into text grants
189-
* no further reach. No audit is projected, matching the existing content read.
190-
*/
191142
/**
192143
* Turns stored bytes into text without ever answering `500`.
193144
*
@@ -208,15 +159,30 @@ async function parseFileText(content: Buffer, extension: string, fileName: strin
208159
return { content: '', metadata: {} }
209160
}
210161
try {
211-
return await parseBuffer(content, extension)
162+
return await parseWorkspaceFileText(content, extension, {
163+
maxTextBytes: MAX_TEXT_EXTRACTION_BYTES,
164+
})
212165
} catch (error) {
166+
if (isPayloadSizeLimitError(error) || getFileParserErrorCode(error) === 'complexity_limit') {
167+
throw new OrchestrationError(
168+
'payload_too_large',
169+
`"${fileName}" exceeds complete text extraction limits`
170+
)
171+
}
213172
throw new OrchestrationError(
214173
'conflict',
215174
`"${fileName}" could not be read as text: ${getErrorMessage(error, 'the stored bytes could not be parsed')}`
216175
)
217176
}
218177
}
219178

179+
/**
180+
* Extracts a workspace file's text.
181+
*
182+
* Runs on `files.read_content` unchanged: extracting text reads exactly the
183+
* bytes that operation already authorizes, and turning them into text grants
184+
* no further reach. No audit is projected, matching the existing content read.
185+
*/
220186
export const readWorkspaceFileText = defineAuthorizedWorkspaceFileUseCase({
221187
operation: fileOperations.readContent,
222188
resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input),

0 commit comments

Comments
 (0)