diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 616244da295..fd373749aa3 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -1040,6 +1040,11 @@ export const FunctionExecute: ToolCatalogEntry = { description: 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, + timeout: { + type: 'number', + description: + 'Optional maximum execution time in seconds. If omitted, Copilot sends 10 seconds by default. Override when needed; capped at the default execution limit.', + }, }, required: ['code'], }, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index bb329500012..9cd31211322 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -4,7 +4,7 @@ export type JsonSchema = unknown -interface ToolRuntimeSchemaEntry { +export interface ToolRuntimeSchemaEntry { parameters?: JsonSchema resultSchema?: JsonSchema } @@ -863,6 +863,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Table ID to overwrite with the code\'s return value. Code MUST return an array of objects where keys match column names. All existing rows are replaced. Example: "tbl_abc123"', }, + timeout: { + type: 'number', + description: + 'Optional maximum execution time in seconds. If omitted, Copilot sends 10 seconds by default. Override when needed; capped at the default execution limit.', + }, }, required: ['code'], }, diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 4ae97dd45c7..a0cd2eec358 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' const { isKnownTool, isSimExecuted } = vi.hoisted(() => ({ isKnownTool: vi.fn(), @@ -58,4 +59,107 @@ describe('copilot tool executor fallback', () => { ) expect(result).toEqual({ success: true, output: { emails: [] } }) }) + + it('converts function_execute timeout from seconds to milliseconds for copilot calls', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1', timeout: 7 }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: 7000, + _context: expect.objectContaining({ + copilotToolExecution: true, + }), + }), + false + ) + }) + + it('defaults copilot function_execute timeout to 10 seconds when omitted', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: 10_000, + }), + false + ) + }) + + it('defaults copilot function_execute timeout to 10 seconds when invalid', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1', timeout: 0 }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: 10_000, + }), + false + ) + }) + + it('does not let copilot function_execute timeout exceed the default execution limit', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) + + await executeTool( + 'function_execute', + { code: 'return 1', timeout: 10_000 }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + copilotToolExecution: true, + } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + timeout: DEFAULT_EXECUTION_TIMEOUT_MS, + }), + false + ) + }) }) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 7658a25e70e..869228970bf 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' import { isKnownTool, isSimExecuted } from './router' import type { @@ -10,6 +11,9 @@ import type { } from './types' const logger = createLogger('ToolExecutor') +const FUNCTION_EXECUTE_TOOL_ID = 'function_execute' +const DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS = 10 +const MILLISECONDS_PER_SECOND = 1000 const handlerRegistry = new Map() @@ -38,7 +42,7 @@ export async function executeTool( ): Promise { const canUseRegisteredHandler = isKnownTool(toolId) && isSimExecuted(toolId) if (!canUseRegisteredHandler) { - const appParams = buildAppToolParams(params, context) + const appParams = buildAppToolParams(toolId, params, context) return executeAppTool(toolId, appParams, false) } @@ -95,11 +99,27 @@ async function executeToolBatch( } function buildAppToolParams( + toolId: string, params: Record, context: ToolExecutionContext ): Record { const result = { ...params } + if (toolId === FUNCTION_EXECUTE_TOOL_ID && context.copilotToolExecution) { + const rawTimeoutSeconds = + result.timeout === undefined || result.timeout === null + ? DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + : Number(result.timeout) + const timeoutSeconds = + Number.isFinite(rawTimeoutSeconds) && rawTimeoutSeconds > 0 + ? rawTimeoutSeconds + : DEFAULT_FUNCTION_EXECUTE_TIMEOUT_SECONDS + result.timeout = Math.min( + Math.ceil(timeoutSeconds * MILLISECONDS_PER_SECOND), + DEFAULT_EXECUTION_TIMEOUT_MS + ) + } + if (result.credentialId && !result.credential && !result.oauthCredential) { result.credential = result.credentialId } diff --git a/apps/sim/tools/function/types.ts b/apps/sim/tools/function/types.ts index d44d509174e..decb3978871 100644 --- a/apps/sim/tools/function/types.ts +++ b/apps/sim/tools/function/types.ts @@ -7,6 +7,10 @@ export interface CodeExecutionInput { sourceCode?: string language?: CodeLanguage useLocalVM?: boolean + /** + * Workflow Function blocks pass milliseconds. Copilot/Mothership tool calls pass seconds + * and are converted at the request boundary. + */ timeout?: number memoryLimit?: number outputPath?: string @@ -28,6 +32,7 @@ export interface CodeExecutionInput { allowLargeValueWorkflowScope?: boolean userId?: string workspaceId?: string + copilotToolExecution?: boolean } isCustomTool?: boolean _sandboxFiles?: Array<{ path: string; content: string }>