Files
simstudioai--sim/apps/sim/lib/copilot/vfs/file-reader.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

425 lines
16 KiB
TypeScript

import { type Span, trace } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
CopilotVfsOutcome,
CopilotVfsReadOutcome,
CopilotVfsReadPath,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { recordFileRead } from '@/lib/copilot/request/metrics'
import { markSpanForError } from '@/lib/copilot/request/otel'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
// Lazy tracer (same pattern as lib/copilot/request/otel.ts).
function getVfsTracer() {
return trace.getTracer('sim-copilot-vfs', '1.0.0')
}
function recordSpanError(span: Span, err: unknown) {
markSpanForError(span, err)
}
const logger = createLogger('FileReader')
const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
// Parseable-document byte cap. Large office/PDF files can still
// produce huge extracted text; reject up front to avoid wasting a
// download + parse only to blow past the tool-result budget.
const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
const MAX_IMAGE_DIMENSION = 1568
const IMAGE_RESIZE_DIMENSIONS = [1568, 1280, 1024, 768]
const IMAGE_QUALITY_STEPS = [85, 70, 55, 40]
const TEXT_TYPES = new Set([
'text/plain',
'text/csv',
'text/markdown',
'text/html',
'text/xml',
'text/x-pptxgenjs',
'text/x-docxjs',
'text/x-python-pdf',
'text/x-python-xlsx',
'application/json',
'application/xml',
'application/javascript',
])
const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt'])
function isReadableType(contentType: string): boolean {
return TEXT_TYPES.has(contentType) || contentType.startsWith('text/')
}
function getExtension(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : ''
}
function detectImageMime(buf: Buffer, claimed: string): string {
if (buf.length < 12) return claimed
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png'
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif'
if (buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50)
return 'image/webp'
return claimed
}
interface PreparedVisionImage {
buffer: Buffer
mediaType: string
resized: boolean
}
/**
* Prepare an image for vision models: detect media type, optionally
* resize/compress with sharp, and return the prepared buffer.
*
* Wrapped in a `copilot.vfs.prepare_image` span so the external trace
* shows exactly when an image read blocked the request on CPU-heavy
* encode attempts. Attributes record input dimensions, whether a resize
* was needed, how many encode attempts it took, and the final
* dimension/quality chosen.
*/
async function prepareImageForVision(
buffer: Buffer,
claimedType: string
): Promise<PreparedVisionImage | null> {
return getVfsTracer().startActiveSpan(
TraceSpan.CopilotVfsPrepareImage,
{
attributes: {
[TraceAttr.CopilotVfsInputBytes]: buffer.length,
[TraceAttr.CopilotVfsInputMediaTypeClaimed]: claimedType,
},
},
async (span) => {
try {
const mediaType = detectImageMime(buffer, claimedType)
span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, mediaType)
let sharpModule: typeof import('sharp')
try {
sharpModule = (await import('sharp')).default
} catch (err) {
logger.warn('Failed to load sharp for image preparation', {
mediaType,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotVfsSharpLoadFailed, true)
const fitsWithoutSharp = buffer.length <= MAX_IMAGE_READ_BYTES
span.setAttribute(
TraceAttr.CopilotVfsOutcome,
fitsWithoutSharp ? 'passthrough_no_sharp' : 'rejected_no_sharp'
)
return fitsWithoutSharp ? { buffer, mediaType, resized: false } : null
}
let metadata: Awaited<ReturnType<ReturnType<typeof sharpModule>['metadata']>>
try {
metadata = await sharpModule(buffer, { limitInputPixels: false }).metadata()
} catch (err) {
logger.warn('Failed to read image metadata for VFS read', {
mediaType,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotVfsMetadataFailed, true)
const fitsWithoutSharp = buffer.length <= MAX_IMAGE_READ_BYTES
span.setAttribute(
TraceAttr.CopilotVfsOutcome,
fitsWithoutSharp ? 'passthrough_no_metadata' : 'rejected_no_metadata'
)
return fitsWithoutSharp ? { buffer, mediaType, resized: false } : null
}
const width = metadata.width ?? 0
const height = metadata.height ?? 0
span.setAttributes({
[TraceAttr.CopilotVfsInputWidth]: width,
[TraceAttr.CopilotVfsInputHeight]: height,
})
const needsResize =
buffer.length > MAX_IMAGE_READ_BYTES ||
width > MAX_IMAGE_DIMENSION ||
height > MAX_IMAGE_DIMENSION
if (!needsResize) {
span.setAttributes({
[TraceAttr.CopilotVfsResized]: false,
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.PassthroughFitsBudget,
[TraceAttr.CopilotVfsOutputBytes]: buffer.length,
[TraceAttr.CopilotVfsOutputMediaType]: mediaType,
})
return { buffer, mediaType, resized: false }
}
const hasAlpha = Boolean(
metadata.hasAlpha ||
mediaType === 'image/png' ||
mediaType === 'image/webp' ||
mediaType === 'image/gif'
)
span.setAttribute(TraceAttr.CopilotVfsHasAlpha, hasAlpha)
let attempts = 0
for (const dimension of IMAGE_RESIZE_DIMENSIONS) {
for (const quality of IMAGE_QUALITY_STEPS) {
attempts += 1
try {
const pipeline = sharpModule(buffer, { limitInputPixels: false }).rotate().resize({
width: dimension,
height: dimension,
fit: 'inside',
withoutEnlargement: true,
})
const transformed = hasAlpha
? {
buffer: await pipeline
.webp({ quality, alphaQuality: quality, effort: 4 })
.toBuffer(),
mediaType: 'image/webp',
}
: {
buffer: await pipeline
.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer(),
mediaType: 'image/jpeg',
}
span.addEvent(TraceEvent.CopilotVfsResizeAttempt, {
[TraceAttr.CopilotVfsResizeDimension]: dimension,
[TraceAttr.CopilotVfsResizeQuality]: quality,
[TraceAttr.CopilotVfsResizeOutputBytes]: transformed.buffer.length,
[TraceAttr.CopilotVfsResizeFitsBudget]:
transformed.buffer.length <= MAX_IMAGE_READ_BYTES,
})
if (transformed.buffer.length <= MAX_IMAGE_READ_BYTES) {
logger.info('Resized image for VFS read', {
originalBytes: buffer.length,
outputBytes: transformed.buffer.length,
originalWidth: width || undefined,
originalHeight: height || undefined,
maxDimension: dimension,
quality,
originalMediaType: mediaType,
outputMediaType: transformed.mediaType,
})
span.setAttributes({
[TraceAttr.CopilotVfsResized]: true,
[TraceAttr.CopilotVfsResizeAttempts]: attempts,
[TraceAttr.CopilotVfsResizeChosenDimension]: dimension,
[TraceAttr.CopilotVfsResizeChosenQuality]: quality,
[TraceAttr.CopilotVfsOutputBytes]: transformed.buffer.length,
[TraceAttr.CopilotVfsOutputMediaType]: transformed.mediaType,
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.Resized,
})
return {
buffer: transformed.buffer,
mediaType: transformed.mediaType,
resized: true,
}
}
} catch (err) {
logger.warn('Failed image resize attempt for VFS read', {
mediaType,
dimension,
quality,
error: toError(err).message,
})
span.addEvent(TraceEvent.CopilotVfsResizeAttemptFailed, {
[TraceAttr.CopilotVfsResizeDimension]: dimension,
[TraceAttr.CopilotVfsResizeQuality]: quality,
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
})
}
}
}
span.setAttributes({
[TraceAttr.CopilotVfsResized]: false,
[TraceAttr.CopilotVfsResizeAttempts]: attempts,
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.RejectedTooLargeAfterResize,
})
return null
} catch (err) {
recordSpanError(span, err)
throw err
} finally {
span.end()
}
}
)
}
export interface FileReadResult {
content: string
totalLines: number
attachment?: {
type: string
name?: string
source: {
type: 'base64'
media_type: string
data: string
}
}
}
/**
* Read and return the content of a workspace file record.
* Handles images (base64 attachment), parseable documents (PDF, DOCX, etc.),
* binary files, and plain text with size guards.
*
* Wrapped in `copilot.vfs.read_file` so the parent mothership trace shows
* per-file read latency, the path taken (image / text / parseable /
* binary), and any size rejection. The `prepareImageForVision` span
* nests underneath for the image-resize path.
*/
export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileReadResult | null> {
const startedAt = Date.now()
const result = await getVfsTracer().startActiveSpan(
TraceSpan.CopilotVfsReadFile,
{
attributes: {
[TraceAttr.CopilotVfsFileName]: record.name,
[TraceAttr.CopilotVfsFileMediaType]: record.type,
[TraceAttr.CopilotVfsFileSizeBytes]: record.size,
[TraceAttr.CopilotVfsFileExtension]: getExtension(record.name),
},
},
async (span) => {
try {
if (isImageFileType(record.type)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
const originalBuffer = await fetchWorkspaceFileBuffer(record)
const prepared = await prepareImageForVision(originalBuffer, record.type)
if (!prepared) {
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
return {
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB after resize/compression)]`,
totalLines: 1,
}
}
const sizeKb = (prepared.buffer.length / 1024).toFixed(1)
const resizeNote = prepared.resized ? ', resized for vision' : ''
span.setAttributes({
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.ImagePrepared,
[TraceAttr.CopilotVfsReadOutputBytes]: prepared.buffer.length,
[TraceAttr.CopilotVfsReadOutputMediaType]: prepared.mediaType,
[TraceAttr.CopilotVfsReadImageResized]: prepared.resized,
})
return {
content: `Image: ${record.name} (${sizeKb}KB, ${prepared.mediaType}${resizeNote})`,
totalLines: 1,
attachment: {
type: 'image',
name: record.name,
source: {
type: 'base64' as const,
media_type: prepared.mediaType,
data: prepared.buffer.toString('base64'),
},
},
}
}
if (isReadableType(record.type)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Text)
if (record.size > MAX_TEXT_READ_BYTES) {
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge)
return {
content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`,
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
const content = buffer.toString('utf-8')
const lines = content.split('\n').length
span.setAttributes({
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.TextRead,
[TraceAttr.CopilotVfsReadOutputBytes]: buffer.length,
[TraceAttr.CopilotVfsReadOutputLines]: lines,
})
return { content, totalLines: lines }
}
const ext = getExtension(record.name)
if (PARSEABLE_EXTENSIONS.has(ext)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.ParseableDocument)
if (record.size > MAX_PARSEABLE_READ_BYTES) {
span.setAttribute(
TraceAttr.CopilotVfsReadOutcome,
CopilotVfsReadOutcome.DocumentTooLarge
)
return {
content: `[Document too large to parse inline: ${record.name} (${record.size} bytes, limit ${MAX_PARSEABLE_READ_BYTES})]`,
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
try {
const { parseBuffer } = await import('@/lib/file-parsers')
const result = await parseBuffer(buffer, ext)
const content = result.content || ''
const lines = content.split('\n').length
span.setAttributes({
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.DocumentParsed,
[TraceAttr.CopilotVfsReadOutputBytes]: content.length,
[TraceAttr.CopilotVfsReadOutputLines]: lines,
})
return { content, totalLines: lines }
} catch (parseErr) {
logger.warn('Failed to parse document', {
fileName: record.name,
ext,
error: toError(parseErr).message,
})
span.addEvent(TraceEvent.CopilotVfsParseFailed, {
[TraceAttr.ErrorMessage]: toError(parseErr).message.slice(0, 500),
})
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ParseFailed)
return {
content: `[Could not parse ${record.name} (${record.type}, ${record.size} bytes)]`,
totalLines: 1,
}
}
}
span.setAttributes({
[TraceAttr.CopilotVfsReadPath]: CopilotVfsReadPath.Binary,
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.BinaryPlaceholder,
})
return {
content: `[Binary file: ${record.name} (${record.type}, ${record.size} bytes). Cannot display as text.]`,
totalLines: 1,
}
} catch (err) {
logger.warn('Failed to read workspace file', {
fileName: record.name,
error: toError(err).message,
})
recordSpanError(span, err)
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ReadFailed)
return null
} finally {
span.end()
}
}
)
// Durable read duration + size by coarse outcome (the fine-grained outcome —
// ImageTooLarge / ParseFailed / etc. — stays on the Tempo span). readFileRecord
// returns null on failure rather than throwing.
recordFileRead(result ? 'success' : 'read_failed', Date.now() - startedAt, record.size ?? 0)
return result
}