diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 441bf788d9..24e992401b 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -610,6 +610,7 @@ export async function POST(req: NextRequest) { workflowVariables = {}, workflowId, isCustomTool = false, + _sandboxFiles, } = body const executionParams = { ...params } @@ -722,6 +723,7 @@ export async function POST(req: NextRequest) { code: codeForE2B, language: CodeLanguage.JavaScript, timeoutMs: timeout, + sandboxFiles: _sandboxFiles, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -785,6 +787,7 @@ export async function POST(req: NextRequest) { code: codeForE2B, language: CodeLanguage.Python, timeoutMs: timeout, + sandboxFiles: _sandboxFiles, }) const executionTime = Date.now() - execStart stdout += e2bStdout diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 9a6ca2f53f..04aff5c1be 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -26,6 +26,7 @@ import type { import { SUBAGENT_LABELS } from '../types' import { extractFileResource, + extractFunctionExecuteResource, extractResourcesFromHistory, extractTableResource, extractWorkflowResource, @@ -361,6 +362,22 @@ export function useChat(workspaceId: string, initialChatId?: string): UseChatRet queryKey: workspaceFilesKeys.content(workspaceId, resource.id), }) } + } else if (toolName === 'function_execute') { + resource = extractFunctionExecuteResource(parsed, storedArgs) + if (resource) { + if (resource.type === 'table') { + lastTableId = resource.id + queryClient.invalidateQueries({ queryKey: tableKeys.detail(resource.id) }) + queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(resource.id) }) + } else if (resource.type === 'file') { + queryClient.invalidateQueries({ + queryKey: workspaceFilesKeys.list(workspaceId), + }) + queryClient.invalidateQueries({ + queryKey: workspaceFilesKeys.content(workspaceId, resource.id), + }) + } + } } else if (toolName === 'create_workflow' || toolName === 'edit_workflow') { resource = extractWorkflowResource(parsed, lastWorkflowId) if (resource) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/utils.ts index 9ea27acebd..728eb3619a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/utils.ts @@ -6,6 +6,7 @@ export const RESOURCE_TOOL_NAMES = new Set([ 'workspace_file', 'create_workflow', 'edit_workflow', + 'function_execute', ]) function getResultData(parsed: SSEPayload): Record | undefined { @@ -60,6 +61,34 @@ export function extractFileResource( return null } +export function extractFunctionExecuteResource( + parsed: SSEPayload, + storedArgs: Record | undefined +): MothershipResource | null { + const topResult = (parsed.result ?? + (typeof parsed.data === 'object' ? parsed.data?.result : undefined)) as + | Record + | undefined + + if (topResult?.tableId) { + return { + type: 'table', + id: topResult.tableId as string, + title: (topResult.tableName as string) || 'Table', + } + } + + if (topResult?.fileId) { + return { + type: 'file', + id: topResult.fileId as string, + title: (topResult.fileName as string) || 'File', + } + } + + return null +} + export function extractWorkflowResource( parsed: SSEPayload, fallbackWorkflowId: string | null @@ -113,6 +142,9 @@ export function extractResourcesFromHistory(messages: TaskStoredMessage[]): Moth if (resource) lastTableId = resource.id } else if (tc.name === 'workspace_file') { resource = extractFileResource(payload, args) + } else if (tc.name === 'function_execute') { + resource = extractFunctionExecuteResource(payload, args) + if (resource?.type === 'table') lastTableId = resource.id } else if (tc.name === 'create_workflow' || tc.name === 'edit_workflow') { resource = extractWorkflowResource(payload, lastWorkflowId) if (resource) lastWorkflowId = resource.id diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts index ee10cf2188..1720f5f89f 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts +++ b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts @@ -1,4 +1,7 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import { TOOL_DECISION_INITIAL_POLL_MS, TOOL_DECISION_MAX_POLL_MS, @@ -18,6 +21,7 @@ import type { StreamingContext, ToolCallResult, } from '@/lib/copilot/orchestrator/types' +import { getTableById } from '@/lib/table/service' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' const logger = createLogger('CopilotSseToolExecution') @@ -94,6 +98,114 @@ async function maybeWriteOutputToFile( } } +const MAX_OUTPUT_TABLE_ROWS = 10_000 +const BATCH_CHUNK_SIZE = 500 + +async function maybeWriteOutputToTable( + toolName: string, + params: Record | undefined, + result: ToolCallResult, + context: ExecutionContext +): Promise { + if (toolName !== 'function_execute') return result + if (!result.success || !result.output) return result + if (!context.workspaceId || !context.userId) return result + + const outputTable = params?.outputTable as string | undefined + if (!outputTable) return result + + try { + const table = await getTableById(outputTable) + if (!table) { + return { + success: false, + error: `Table "${outputTable}" not found`, + } + } + + const rawOutput = result.output + let rows: Array> + + if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) { + const inner = (rawOutput as Record).result + if (Array.isArray(inner)) { + rows = inner + } else { + return { + success: false, + error: 'outputTable requires the code to return an array of objects', + } + } + } else if (Array.isArray(rawOutput)) { + rows = rawOutput + } else { + return { + success: false, + error: 'outputTable requires the code to return an array of objects', + } + } + + if (rows.length > MAX_OUTPUT_TABLE_ROWS) { + return { + success: false, + error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, + } + } + + if (rows.length === 0) { + return { + success: false, + error: 'outputTable requires at least one row — code returned an empty array', + } + } + + await db.transaction(async (tx) => { + await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) + + const now = new Date() + for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { + const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) + const values = chunk.map((rowData, j) => ({ + id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + tableId: outputTable, + workspaceId: context.workspaceId!, + data: rowData, + position: i + j, + createdAt: now, + updatedAt: now, + createdBy: context.userId, + })) + await tx.insert(userTableRows).values(values) + } + }) + + logger.info('Tool output written to table', { + toolName, + tableId: outputTable, + rowCount: rows.length, + }) + + return { + success: true, + output: { + message: `Wrote ${rows.length} rows to table ${outputTable}`, + tableId: outputTable, + rowCount: rows.length, + }, + } + } catch (err) { + logger.warn('Failed to write tool output to table', { + toolName, + outputTable, + error: err instanceof Error ? err.message : String(err), + }) + return { + success: false, + error: `Failed to write to table: ${err instanceof Error ? err.message : String(err)}`, + } + } +} + export async function executeToolAndReport( toolCallId: string, context: StreamingContext, @@ -117,6 +229,7 @@ export async function executeToolAndReport( try { let result = await executeToolServerSide(toolCall, execContext) result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext) + result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext) toolCall.status = result.success ? 'success' : 'error' toolCall.result = result toolCall.error = result.error diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts index 11e5e8f772..f4f5344b76 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts @@ -12,6 +12,11 @@ import { generateRequestId } from '@/lib/core/utils/request' import { getCredentialActorContext } from '@/lib/credentials/access' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { getTableById, queryRows } from '@/lib/table/service' +import { + downloadWorkspaceFile, + listWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' @@ -143,6 +148,97 @@ export async function executeIntegrationToolDirect( executionParams.blockNameMapping = {} executionParams.language = executionParams.language || 'javascript' executionParams.timeout = executionParams.timeout || 30000 + + if (isHosted && workspaceId) { + const sandboxFiles: Array<{ path: string; content: string }> = [] + const MAX_FILE_SIZE = 10 * 1024 * 1024 + const MAX_TOTAL_SIZE = 50 * 1024 * 1024 + const TEXT_EXTENSIONS = new Set([ + 'csv', + 'json', + 'txt', + 'md', + 'html', + 'xml', + 'tsv', + 'yaml', + 'yml', + ]) + let totalSize = 0 + + const inputFilePaths = executionParams.inputFiles as string[] | undefined + if (inputFilePaths?.length) { + const allFiles = await listWorkspaceFiles(workspaceId) + for (const filePath of inputFilePaths) { + const fileName = filePath.replace(/^files\//, '') + const ext = fileName.split('.').pop()?.toLowerCase() ?? '' + if (!TEXT_EXTENSIONS.has(ext)) { + logger.warn('Skipping non-text sandbox input file', { fileName, ext }) + continue + } + const record = allFiles.find( + (f) => f.name === fileName || f.name.normalize('NFC') === fileName.normalize('NFC') + ) + if (!record) { + logger.warn('Sandbox input file not found', { fileName }) + continue + } + if (record.size > MAX_FILE_SIZE) { + logger.warn('Sandbox input file exceeds size limit', { fileName, size: record.size }) + continue + } + if (totalSize + record.size > MAX_TOTAL_SIZE) { + logger.warn('Sandbox input total size limit reached, skipping remaining files') + break + } + const buffer = await downloadWorkspaceFile(record) + totalSize += buffer.length + sandboxFiles.push({ path: `/home/user/${fileName}`, content: buffer.toString('utf-8') }) + } + } + + const inputTableIds = executionParams.inputTables as string[] | undefined + if (inputTableIds?.length) { + for (const tableId of inputTableIds) { + const table = await getTableById(tableId) + if (!table) { + logger.warn('Sandbox input table not found', { tableId }) + continue + } + const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input') + const cols = (table.schema as { columns: Array<{ name: string }> }).columns.map( + (c) => c.name + ) + const csvLines = [cols.join(',')] + for (const row of rows) { + csvLines.push( + cols + .map((c) => JSON.stringify((row.data as Record)[c] ?? '')) + .join(',') + ) + } + const csvContent = csvLines.join('\n') + if (totalSize + csvContent.length > MAX_TOTAL_SIZE) { + logger.warn('Sandbox input total size limit reached, skipping remaining tables') + break + } + totalSize += csvContent.length + sandboxFiles.push({ path: `/home/user/tables/${tableId}.csv`, content: csvContent }) + } + } + + if (sandboxFiles.length > 0) { + executionParams._sandboxFiles = sandboxFiles + logger.info('Prepared sandbox input files', { + fileCount: sandboxFiles.length, + totalSize, + paths: sandboxFiles.map((f) => f.path), + }) + } + + executionParams.inputFiles = undefined + executionParams.inputTables = undefined + } } const result = await executeTool(toolName, executionParams) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2ae7a87afb..bea20140f3 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -22,8 +22,8 @@ export const isTest = env.NODE_ENV === 'test' * Is this the hosted version of the application */ export const isHosted = - getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || - getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' +getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.sim.ai' || +getEnv('NEXT_PUBLIC_APP_URL') === 'https://www.staging.sim.ai' /** * Is billing enforcement enabled diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 0e9fd7e975..fec44b182f 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -3,10 +3,16 @@ import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' import { CodeLanguage } from '@/lib/execution/languages' +export interface SandboxFile { + path: string + content: string +} + export interface E2BExecutionRequest { code: string language: CodeLanguage timeoutMs: number + sandboxFiles?: SandboxFile[] } export interface E2BExecutionResult { @@ -29,6 +35,17 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise f.path), + }) + } + const stdoutChunks = [] try { diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index dc8dee1901..d8e75ca55d 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -80,7 +80,7 @@ export const functionExecuteTool: ToolConfig c.content).join('\n') : params.code - return { + const body: Record = { code: codeContent, language: params.language || DEFAULT_CODE_LANGUAGE, timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS, @@ -93,6 +93,12 @@ export const functionExecuteTool: ToolConfig } export interface CodeExecutionOutput extends ToolResponse {