diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index e172f31d771..bc0070ed58a 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -719,6 +719,7 @@ export async function POST(req: NextRequest) { blockNameMapping = {}, blockOutputSchemas = {}, workflowVariables = {}, + contextVariables: preResolvedContextVariables = {}, workflowId, workspaceId, isCustomTool = false, @@ -743,6 +744,10 @@ export async function POST(req: NextRequest) { // For shell, env vars are injected as OS env vars via shellEnvs. // Replace {{VAR}} placeholders with $VAR so the shell can access them natively. resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, '$$$1') + // Carry pre-resolved block output variables (e.g. __blockRef_N) so they can be + // injected as shell env vars below. The executor replaces block references in the + // code with these names, so the values must be present at runtime. + contextVariables = { ...preResolvedContextVariables } } else { const codeResolution = resolveCodeVariables( code, @@ -755,7 +760,10 @@ export async function POST(req: NextRequest) { lang ) resolvedCode = codeResolution.resolvedCode - contextVariables = codeResolution.contextVariables + // Merge pre-resolved block output variables from the executor. These take precedence + // because they were produced by the resolver using full execution-state context + // (including loop/parallel scope) and should not be overwritten. + contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables } } let jsImports = '' diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 5044eab5639..ce06bae5d73 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -42,7 +42,10 @@ import { } from '@/executor/utils/iteration-context' import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' -import type { VariableResolver } from '@/executor/variables/resolver' +import { + FUNCTION_BLOCK_CONTEXT_VARS_KEY, + type VariableResolver, +} from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' @@ -108,7 +111,13 @@ export class BlockExecutor { await validateBlockType(ctx.userId, blockType, ctx) } - resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) + if (block.metadata?.id === BlockType.FUNCTION) { + const { resolvedInputs: fnInputs, contextVariables } = + this.resolver.resolveInputsForFunctionBlock(ctx, node.id, block.config.params, block) + resolvedInputs = { ...fnInputs, [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables } + } else { + resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) + } if (blockLog) { blockLog.input = this.sanitizeInputsForLog(resolvedInputs) @@ -418,7 +427,7 @@ export class BlockExecutor { const result: Record = {} for (const [key, value] of Object.entries(inputs)) { - if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode') { + if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY) { continue } diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index 68302412bcb..33558ae4ad6 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -3,6 +3,7 @@ import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' +import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -25,6 +26,10 @@ export class FunctionBlockHandler implements BlockHandler { const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) + const contextVariables = (inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as + | Record + | undefined) ?? {} + const result = await executeTool( 'function_execute', { @@ -36,6 +41,7 @@ export class FunctionBlockHandler implements BlockHandler { blockData, blockNameMapping, blockOutputSchemas, + contextVariables, _context: { workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 88b23d72340..1294675c2b3 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -15,6 +15,9 @@ import { import { WorkflowResolver } from '@/executor/variables/resolvers/workflow' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' +/** Key used to carry pre-resolved context variables through the inputs map. */ +export const FUNCTION_BLOCK_CONTEXT_VARS_KEY = '_runtimeContextVars' + const logger = createLogger('VariableResolver') export class VariableResolver { @@ -36,6 +39,63 @@ export class VariableResolver { ] } + /** + * Resolves inputs for function blocks. Block output references in the `code` field + * are stored as named context variables instead of being embedded as JavaScript + * literals, preventing large values from bloating the code string. + * + * Returns the resolved inputs and a `contextVariables` map. Callers should inject + * contextVariables into the function execution request body so the isolated VM can + * access them as global variables. + */ + resolveInputsForFunctionBlock( + ctx: ExecutionContext, + currentNodeId: string, + params: Record, + block: SerializedBlock + ): { resolvedInputs: Record; contextVariables: Record } { + const contextVariables: Record = {} + const resolved: Record = {} + + for (const [key, value] of Object.entries(params)) { + if (key === 'code') { + if (typeof value === 'string') { + resolved[key] = this.resolveCodeWithContextVars( + ctx, + currentNodeId, + value, + undefined, + block, + contextVariables + ) + } else if (Array.isArray(value)) { + resolved[key] = value.map((item: any) => { + if (item && typeof item === 'object' && typeof item.content === 'string') { + return { + ...item, + content: this.resolveCodeWithContextVars( + ctx, + currentNodeId, + item.content, + undefined, + block, + contextVariables + ), + } + } + return item + }) + } else { + resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block) + } + } else { + resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block) + } + } + + return { resolvedInputs: resolved, contextVariables } + } + resolveInputs( ctx: ExecutionContext, currentNodeId: string, @@ -149,6 +209,83 @@ export class VariableResolver { } return value } + /** + * Resolves a code template for a function block. Block output references are stored + * in `contextVarAccumulator` as named variables (e.g. `__blockRef_0`) and replaced + * with those variable names in the returned code string. Non-block references (loop + * items, workflow variables, env vars) are still inlined as literals so they remain + * available without any extra passing mechanism. + */ + private resolveCodeWithContextVars( + ctx: ExecutionContext, + currentNodeId: string, + template: string, + loopScope: LoopScope | undefined, + block: SerializedBlock, + contextVarAccumulator: Record + ): string { + const resolutionContext: ResolutionContext = { + executionContext: ctx, + executionState: this.state, + currentNodeId, + loopScope, + } + + const language = (block.config?.params as Record | undefined)?.language as + | string + | undefined + + let replacementError: Error | null = null + + const blockRefByMatch = new Map() + + let result = replaceValidReferences(template, (match) => { + if (replacementError) return match + + try { + if (this.blockResolver.canResolve(match)) { + // Deduplicate: identical references in the same template share a single + // accumulator slot so we do not duplicate large payloads. + const existing = blockRefByMatch.get(match) + if (existing !== undefined) return existing + + const resolved = this.resolveReference(match, resolutionContext) + if (resolved === undefined) return match + + const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved + + // Block output: store in contextVarAccumulator, replace with variable name + const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}` + contextVarAccumulator[varName] = effectiveValue + blockRefByMatch.set(match, varName) + return varName + } + + const resolved = this.resolveReference(match, resolutionContext) + if (resolved === undefined) return match + + const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved + + // Non-block reference (loop, parallel, workflow, env): embed as literal + return this.blockResolver.formatValueForBlock(effectiveValue, BlockType.FUNCTION, language) + } catch (error) { + replacementError = error instanceof Error ? error : new Error(String(error)) + return match + } + }) + + if (replacementError !== null) { + throw replacementError + } + + result = result.replace(createEnvVarPattern(), (match) => { + const resolved = this.resolveReference(match, resolutionContext) + return typeof resolved === 'string' ? resolved : match + }) + + return result + } + private resolveTemplate( ctx: ExecutionContext, currentNodeId: string, diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 844b1c6d515..59873843cbb 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -128,6 +128,7 @@ export const functionExecuteTool: ToolConfig blockNameMapping?: Record blockOutputSchemas?: Record> + /** Pre-resolved block output variables from the executor, injected as VM globals. */ + contextVariables?: Record _context?: { workflowId?: string userId?: string