diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 10ba254a19ce..729b5c637e4f 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -731,13 +731,34 @@ const getPreviewContextSuffix = (file) => { : ' (preview unavailable)'; }; +const OFFICE_DOCUMENT_EXTENSIONS = new Set([ + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.odt', + '.ods', + '.odp', +]); + +const getOfficeDocumentContextSuffix = (file) => { + const ext = path.extname(file.filename ?? '').toLowerCase(); + if (!OFFICE_DOCUMENT_EXTENSIONS.has(ext)) { + return ''; + } + + return ' (Office document; use bash_tool, not read_file, to inspect or extract text)'; +}; + const getVisibleCodeFileContextLine = (file, agentResourceIds) => { if (file.context === FileContext.execute_code) { return ''; } const fileSuffix = agentResourceIds.has(file.file_id) ? '' : ' (attached by user)'; - return `\n\t- /mnt/data/${file.filename}${fileSuffix}${getPreviewContextSuffix(file)}`; + return `\n\t- /mnt/data/${file.filename}${fileSuffix}${getPreviewContextSuffix(file)}${getOfficeDocumentContextSuffix(file)}`; }; const appendVisibleCodeFileContext = (toolContext, contextLine) => { diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 0bff06adf324..0a612d542991 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -2055,6 +2055,28 @@ describe('Code Process', () => { expect(result.toolContext).not.toContain('preview'); }); + it('annotates Office input files with a bash extraction hint', async () => { + setupSessionInfoOk(); + getFiles.mockResolvedValue([ + makeFile({ + filename: 'employee-report.docx', + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + status: 'ready', + }), + ]); + + const result = await primeFiles({ + req: { user: { id: 'user-123', role: 'USER' } }, + tool_resources: { execute_code: { file_ids: ['fid-ready'], files: [] } }, + agentId: 'agent-id', + }); + + expect(result.toolContext).toContain('/mnt/data/employee-report.docx'); + expect(result.toolContext).toContain('Office document'); + expect(result.toolContext).toContain('bash_tool'); + expect(result.toolContext).toContain('not read_file'); + }); + it('does not annotate a legacy file (no status field, back-compat)', async () => { /* Records pre-dating the deferred-preview flow have no `status`. They * MUST render exactly as before — no suffix at all. */ diff --git a/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index b597e8880ae7..49861bbdf8f9 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -2801,6 +2801,30 @@ describe('createToolExecuteHandler', () => { expect(result.errorMessage).toContain('bash_tool'); }); + it('rejects Office documents with an extraction hint instead of a generic binary error', async () => { + const readSandboxFile = jest.fn(); + const handler = makeReadFileHandler({ + codeEnvAvailable: true, + accessibleSkillIds: skillsInScope(), + readSandboxFile, + }); + + const [result] = await invokeHandler(handler, [ + { + id: 'call_docx', + name: Constants.READ_FILE, + args: { file_path: '/mnt/data/employee-report.docx' }, + }, + ]); + + expect(readSandboxFile).not.toHaveBeenCalled(); + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('Office document'); + expect(result.errorMessage).toContain('bash_tool'); + expect(result.errorMessage).toContain('unzip -p'); + expect(result.errorMessage).toContain('word/document.xml'); + }); + it('is case-insensitive on the extension match (PNG vs .png)', async () => { const readSandboxFile = jest.fn(); const handler = makeReadFileHandler({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 4306ee160145..d6aa42d99734 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -1157,6 +1157,18 @@ const IMAGE_EXTENSIONS_FOR_HINT = new Set([ '.avif', ]); +const OFFICE_EXTENSIONS_FOR_HINT = new Set([ + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.odt', + '.ods', + '.odp', +]); + function lowercaseExtension(filePath: string): string { const dot = filePath.lastIndexOf('.'); const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); @@ -1164,6 +1176,14 @@ function lowercaseExtension(filePath: string): string { return filePath.slice(dot).toLowerCase(); } +function buildOfficeDocumentError(filePath: string, ext: string): string { + if (ext === '.docx') { + return `"${filePath}" is an Office document (${ext}) and cannot be read as text by \`read_file\`. Use \`bash_tool\` to extract text from the container, for example: \`unzip -p ${filePath} word/document.xml | sed -e 's/<[^>]*>/ /g'\`.`; + } + + return `"${filePath}" is an Office document (${ext}) and cannot be read as text by \`read_file\`. Use \`bash_tool\` to inspect or extract it with the format-specific command-line tools available in the sandbox.`; +} + /** * Builds the model-visible error returned when `read_file` is invoked on * a binary path. Phrasing is tuned for the LLM: states the fact (file is @@ -1176,6 +1196,9 @@ function buildBinaryFileError(filePath: string, ext: string): string { if (IMAGE_EXTENSIONS_FOR_HINT.has(ext)) { return `"${filePath}" is an image file (${ext}) and cannot be read as text. The image is already attached to the conversation and visible to the user. To process it programmatically, use \`bash_tool\` (e.g. \`file ${filePath}\` for metadata, or \`python3 -c '...'\` to operate on the bytes).`; } + if (OFFICE_EXTENSIONS_FOR_HINT.has(ext)) { + return buildOfficeDocumentError(filePath, ext); + } return `"${filePath}" is a binary file (${ext}) and cannot be read as text by \`read_file\`. Use \`bash_tool\` to process it (e.g. \`file ${filePath}\` for metadata, or a runtime-appropriate command for the format).`; }