import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isUserFile } from '@/lib/core/utils/user-file' import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' import type { ExecutionContext, UserFile } from '@/executor/types' import type { ToolConfig, ToolFileData } from '@/tools/types' const logger = createLogger('FileToolProcessor') /** * Processes tool outputs and converts file-typed outputs to UserFile objects. * This enables tools to return file data that gets automatically stored in the * execution filesystem and made available as UserFile objects for workflow use. */ export class FileToolProcessor { /** * Process tool outputs and convert file-typed outputs to UserFile objects */ static async processToolOutputs( toolOutput: any, toolConfig: ToolConfig, executionContext: ExecutionContext ): Promise { if (!toolConfig.outputs) { return toolOutput } const processedOutput = { ...toolOutput } for (const [outputKey, outputDef] of Object.entries(toolConfig.outputs)) { if (!FileToolProcessor.isFileOutput(outputDef.type)) { continue } const fileData = processedOutput[outputKey] if (!fileData) { logger.warn(`File-typed output '${outputKey}' is missing from tool result`) continue } try { processedOutput[outputKey] = await FileToolProcessor.processFileOutput( fileData, outputDef.type, outputKey, executionContext ) } catch (error) { logger.error(`Error processing file output '${outputKey}':`, error) const errorMessage = toError(error).message throw new Error(`Failed to process file output '${outputKey}': ${errorMessage}`) } } return processedOutput } /** * Check if an output type is file-related */ private static isFileOutput(type: string): boolean { return type === 'file' || type === 'file[]' } /** * Process a single file output (either single file or array of files) */ private static async processFileOutput( fileData: any, outputType: string, outputKey: string, executionContext: ExecutionContext ): Promise { if (outputType === 'file[]') { return FileToolProcessor.processFileArray(fileData, outputKey, executionContext) } return FileToolProcessor.processFileData(fileData, executionContext) } /** * Process an array of files */ private static async processFileArray( fileData: any, outputKey: string, executionContext: ExecutionContext ): Promise { if (!Array.isArray(fileData)) { throw new Error(`Output '${outputKey}' is marked as file[] but is not an array`) } return Promise.all( fileData.map((file, index) => FileToolProcessor.processFileData(file, executionContext)) ) } /** * Convert various file data formats to UserFile by storing in execution filesystem. * If the input is already a UserFile, returns it unchanged. */ private static async processFileData( fileData: ToolFileData | UserFile, context: ExecutionContext ): Promise { // If already a UserFile (e.g., from tools that handle their own file storage), // return it directly without re-processing if (isUserFile(fileData)) { return fileData as UserFile } const data = fileData as ToolFileData try { let buffer: Buffer | null = null if (Buffer.isBuffer(data.data)) { buffer = data.data } else if ( data.data && typeof data.data === 'object' && 'type' in data.data && 'data' in data.data ) { const serializedBuffer = data.data as { type: string; data: number[] } if (serializedBuffer.type === 'Buffer' && Array.isArray(serializedBuffer.data)) { buffer = Buffer.from(serializedBuffer.data) } else { throw new Error(`Invalid serialized buffer format for ${data.name}`) } } else if (typeof data.data === 'string' && data.data) { let base64Data = data.data if (base64Data.includes('-') || base64Data.includes('_')) { base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/') } buffer = Buffer.from(base64Data, 'base64') } if (!buffer && data.url) { buffer = await downloadFileFromUrl(data.url, { userId: context.userId }) } if (buffer) { if (buffer.length === 0) { throw new Error(`File '${data.name}' has zero bytes`) } return await uploadExecutionFile( { workspaceId: context.workspaceId || '', workflowId: context.workflowId, executionId: context.executionId || '', }, buffer, data.name, data.mimeType, context.userId ) } if (!data.data) { throw new Error( `File data for '${data.name}' must have either 'data' (Buffer/base64) or 'url' property` ) } return uploadFileFromRawData( { name: data.name, data: data.data, mimeType: data.mimeType, }, { workspaceId: context.workspaceId || '', workflowId: context.workflowId, executionId: context.executionId || '', }, context.userId ) } catch (error) { logger.error(`Error processing file data for '${data.name}':`, error) throw error } } /** * Check if a tool has any file-typed outputs */ static hasFileOutputs(toolConfig: ToolConfig): boolean { if (!toolConfig.outputs) { return false } return Object.values(toolConfig.outputs).some( (output) => output.type === 'file' || output.type === 'file[]' ) } }