mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(resolver): use context variables for block outputs in function block code (#4223)
* v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li <theo@sim.ai> * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha * fix: use context variables for block outputs in function block code When a function block references another block's output via <BlockA.result>, the executor previously embedded the full value as a JavaScript literal directly in the code string. For large outputs (>50 KB), this caused the code string to exceed the terminal console display limit, making inputs appear truncated or replaced with { __simTruncated: true } in the UI. Instead, block output references in function block code are now stored as named global variables (__blockRef_N) in the isolated VM context. The code string only contains the compact variable name, keeping it small regardless of the referenced value size. Loop/parallel/env/workflow references are still inlined as literals since the API route has no way to resolve them independently. The _runtimeContextVars key is filtered from sanitizeInputsForLog so it does not appear in execution logs or SSE events. Pre-resolved context variables are merged with any variables produced by the API route resolveCodeVariables, with executor values taking precedence. Fixes #4195 * fix: address Cursor and Greptile bot review comments - Pass preResolvedContextVariables through to shellEnvs for Shell language (Cursor: shell loses pre-resolved block refs, executes against undefined vars) - Remove duplicate CodeExecutionOutput interface declaration (Cursor + Greptile: dead duplicate declaration in tools/function/types.ts) - Deduplicate identical block references in resolveCodeWithContextVars so the same <BlockA.result> reused multiple times shares one __blockRef_N slot (Greptile P2: avoid duplicating large payloads across the wire) * fix: shell block references and complex env value serialization Two follow-ups to the function-block context-variable refactor: - resolveCodeWithContextVars now emits `$__blockRef_N` for shell function blocks so the script dereferences the env var injected by the executor. Other languages still receive the bare identifier. - The function-execute route now JSON-stringifies non-primitive values when building shell env vars, replacing the previous `String(v)` call that produced `[object Object]` for objects/arrays. Co-Authored-By: Octopus <liyuan851277048@icloud.com> * fix lint * review pass * ignore shell comments * update contract * fix tests --------- Co-authored-by: Waleed <walif6@gmail.com> Co-authored-by: Theodore Li <theodoreqili@gmail.com> Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com> Co-authored-by: octo-patch <octo-patch@github.com> Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
co-authored by
Waleed
Theodore Li
Siddharth Ganesan
Vikhyath Mondreti
octo-patch
Vikhyath Mondreti
parent
ae87481d83
commit
7b6aa728ae
@@ -587,6 +587,26 @@ function cleanStdout(stdout: string): string {
|
||||
return stdout
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a value for use as a shell environment variable. Strings pass through
|
||||
* unchanged; primitives are coerced via `String`; objects, arrays, and other complex
|
||||
* values are JSON-stringified so that referencing them via `$VAR` yields a useful
|
||||
* representation instead of `[object Object]`. `null`/`undefined` become an empty
|
||||
* string to match POSIX env semantics.
|
||||
*/
|
||||
function serializeForShellEnv(value: unknown, nullValue = ''): string {
|
||||
if (value === null || value === undefined) return nullValue
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value)
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value) ?? ''
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeExportSandboxFileToWorkspace(args: {
|
||||
authUserId: string
|
||||
workflowId?: string
|
||||
@@ -722,6 +742,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
blockNameMapping = {},
|
||||
blockOutputSchemas = {},
|
||||
workflowVariables = {},
|
||||
contextVariables: preResolvedContextVariables = {},
|
||||
workflowId,
|
||||
workspaceId,
|
||||
isCustomTool = false,
|
||||
@@ -746,6 +767,10 @@ export const POST = withRouteHandler(async (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,
|
||||
@@ -758,7 +783,10 @@ export const POST = withRouteHandler(async (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 = ''
|
||||
@@ -783,10 +811,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
const shellEnvs: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(envVars)) {
|
||||
shellEnvs[k] = String(v)
|
||||
shellEnvs[k] = serializeForShellEnv(v)
|
||||
}
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
shellEnvs[k] = String(v)
|
||||
shellEnvs[k] = serializeForShellEnv(v, 'null')
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] E2B shell execution`, {
|
||||
@@ -893,7 +921,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
|
||||
prologueLineCount++
|
||||
for (const [k, v] of Object.entries(contextVariables)) {
|
||||
prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n`
|
||||
prologue += `globalThis[${JSON.stringify(k)}] = ${formatLiteralForCode(v, 'javascript')};\n`
|
||||
prologue += `const ${k} = globalThis[${JSON.stringify(k)}];\n`
|
||||
prologueLineCount++
|
||||
prologueLineCount++
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,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'
|
||||
|
||||
@@ -115,7 +118,13 @@ export class BlockExecutor {
|
||||
await validateBlockType(ctx.userId, ctx.workspaceId, 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)
|
||||
@@ -428,7 +437,11 @@ export class BlockExecutor {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
@@ -73,10 +74,13 @@ describe('FunctionBlockHandler', () => {
|
||||
blockData: {},
|
||||
blockNameMapping: {},
|
||||
blockOutputSchemas: {},
|
||||
contextVariables: {},
|
||||
_context: {
|
||||
workflowId: mockContext.workflowId,
|
||||
workspaceId: mockContext.workspaceId,
|
||||
userId: mockContext.userId,
|
||||
isDeployedContext: mockContext.isDeployedContext,
|
||||
enforceCredentialAccess: mockContext.enforceCredentialAccess,
|
||||
},
|
||||
}
|
||||
const expectedOutput: any = { result: 'Success' }
|
||||
@@ -110,10 +114,13 @@ describe('FunctionBlockHandler', () => {
|
||||
blockData: {},
|
||||
blockNameMapping: {},
|
||||
blockOutputSchemas: {},
|
||||
contextVariables: {},
|
||||
_context: {
|
||||
workflowId: mockContext.workflowId,
|
||||
workspaceId: mockContext.workspaceId,
|
||||
userId: mockContext.userId,
|
||||
isDeployedContext: mockContext.isDeployedContext,
|
||||
enforceCredentialAccess: mockContext.enforceCredentialAccess,
|
||||
},
|
||||
}
|
||||
const expectedOutput: any = { result: 'Success' }
|
||||
@@ -140,10 +147,13 @@ describe('FunctionBlockHandler', () => {
|
||||
blockData: {},
|
||||
blockNameMapping: {},
|
||||
blockOutputSchemas: {},
|
||||
contextVariables: {},
|
||||
_context: {
|
||||
workflowId: mockContext.workflowId,
|
||||
workspaceId: mockContext.workspaceId,
|
||||
userId: mockContext.userId,
|
||||
isDeployedContext: mockContext.isDeployedContext,
|
||||
enforceCredentialAccess: mockContext.enforceCredentialAccess,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -168,6 +178,24 @@ describe('FunctionBlockHandler', () => {
|
||||
expect(mockExecuteTool).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass runtime context variables to function_execute', async () => {
|
||||
const contextVariables = { __blockRef_0: { result: 'from-block' } }
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
code: 'return globalThis["__blockRef_0"]',
|
||||
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
|
||||
})
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'function_execute',
|
||||
expect.objectContaining({
|
||||
contextVariables,
|
||||
}),
|
||||
false,
|
||||
mockContext
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle tool error with no specific message', async () => {
|
||||
const inputs = { code: 'some code' }
|
||||
const errorResult = { success: false }
|
||||
|
||||
@@ -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,9 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
|
||||
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||
|
||||
const contextVariables =
|
||||
(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as Record<string, unknown> | undefined) ?? {}
|
||||
|
||||
const result = await executeTool(
|
||||
'function_execute',
|
||||
{
|
||||
@@ -36,6 +40,7 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
contextVariables,
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
|
||||
@@ -143,14 +143,14 @@ export function createCombinedPattern(): RegExp {
|
||||
*/
|
||||
export function replaceValidReferences(
|
||||
template: string,
|
||||
replacer: (match: string) => string
|
||||
replacer: (match: string, index: number, template: string) => string
|
||||
): string {
|
||||
const pattern = createReferencePattern()
|
||||
|
||||
return template.replace(pattern, (match) => {
|
||||
return template.replace(pattern, (match, _content, index) => {
|
||||
if (!isLikelyReferenceSegment(match)) {
|
||||
return match
|
||||
}
|
||||
return replacer(match)
|
||||
return replacer(match, index, template)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockType } from '@/executor/constants'
|
||||
import { ExecutionState } from '@/executor/execution/state'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import { VariableResolver } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
|
||||
function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock {
|
||||
return {
|
||||
id,
|
||||
metadata: { id: type, name },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: type, params },
|
||||
inputs: {},
|
||||
outputs: {
|
||||
result: 'string',
|
||||
items: 'json',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function createResolver(language = 'javascript') {
|
||||
const producer = createBlock('producer', 'Producer', BlockType.API)
|
||||
const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, {
|
||||
language,
|
||||
})
|
||||
const workflow: SerializedWorkflow = {
|
||||
version: '1',
|
||||
blocks: [producer, functionBlock],
|
||||
connections: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
const state = new ExecutionState()
|
||||
state.setBlockOutput('producer', {
|
||||
result: 'hello world',
|
||||
items: ['a', 'b'],
|
||||
})
|
||||
const ctx = {
|
||||
blockStates: state.getBlockStates(),
|
||||
blockLogs: [],
|
||||
environmentVariables: {},
|
||||
workflowVariables: {},
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopExecutions: new Map(),
|
||||
executedBlocks: new Set(),
|
||||
activeExecutionPath: new Set(),
|
||||
completedLoops: new Set(),
|
||||
metadata: {},
|
||||
} as ExecutionContext
|
||||
|
||||
return {
|
||||
block: functionBlock,
|
||||
ctx,
|
||||
resolver: new VariableResolver(workflow, {}, state),
|
||||
}
|
||||
}
|
||||
|
||||
describe('VariableResolver function block inputs', () => {
|
||||
it('returns empty inputs when params are missing', () => {
|
||||
const { block, ctx, resolver } = createResolver()
|
||||
|
||||
const result = resolver.resolveInputsForFunctionBlock(ctx, 'function', undefined, block)
|
||||
|
||||
expect(result).toEqual({ resolvedInputs: {}, contextVariables: {} })
|
||||
})
|
||||
|
||||
it('resolves JavaScript block references through globalThis context variables', () => {
|
||||
const { block, ctx, resolver } = createResolver('javascript')
|
||||
|
||||
const result = resolver.resolveInputsForFunctionBlock(
|
||||
ctx,
|
||||
'function',
|
||||
{ code: 'return <Producer.result>' },
|
||||
block
|
||||
)
|
||||
|
||||
expect(result.resolvedInputs.code).toBe('return globalThis["__blockRef_0"]')
|
||||
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
|
||||
})
|
||||
|
||||
it('resolves Python block references through globals lookup', () => {
|
||||
const { block, ctx, resolver } = createResolver('python')
|
||||
|
||||
const result = resolver.resolveInputsForFunctionBlock(
|
||||
ctx,
|
||||
'function',
|
||||
{ code: 'return <Producer.result>' },
|
||||
block
|
||||
)
|
||||
|
||||
expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]')
|
||||
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
|
||||
})
|
||||
|
||||
it('uses separate Python context variables for repeated mutable references', () => {
|
||||
const { block, ctx, resolver } = createResolver('python')
|
||||
|
||||
const result = resolver.resolveInputsForFunctionBlock(
|
||||
ctx,
|
||||
'function',
|
||||
{ code: 'a = <Producer.items>\nb = <Producer.items>\nreturn b' },
|
||||
block
|
||||
)
|
||||
|
||||
expect(result.resolvedInputs.code).toBe(
|
||||
'a = globals()["__blockRef_0"]\nb = globals()["__blockRef_1"]\nreturn b'
|
||||
)
|
||||
expect(result.contextVariables).toEqual({
|
||||
__blockRef_0: ['a', 'b'],
|
||||
__blockRef_1: ['a', 'b'],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses shell-safe expansions for block references', () => {
|
||||
const { block, ctx, resolver } = createResolver('shell')
|
||||
|
||||
const result = resolver.resolveInputsForFunctionBlock(
|
||||
ctx,
|
||||
'function',
|
||||
{ code: 'echo <Producer.result>suffix && echo "<Producer.result>"' },
|
||||
block
|
||||
)
|
||||
|
||||
expect(result.resolvedInputs.code).toBe(
|
||||
`echo "\${__blockRef_0}"suffix && echo "\${__blockRef_1}"`
|
||||
)
|
||||
expect(result.contextVariables).toEqual({
|
||||
__blockRef_0: 'hello world',
|
||||
__blockRef_1: 'hello world',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores shell comment quotes when formatting later block references', () => {
|
||||
const { block, ctx, resolver } = createResolver('shell')
|
||||
|
||||
const result = resolver.resolveInputsForFunctionBlock(
|
||||
ctx,
|
||||
'function',
|
||||
{ code: "# don't confuse quote tracking\necho <Producer.result>" },
|
||||
block
|
||||
)
|
||||
|
||||
expect(result.resolvedInputs.code).toBe(
|
||||
`# don't confuse quote tracking\necho "\${__blockRef_0}"`
|
||||
)
|
||||
expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
|
||||
})
|
||||
})
|
||||
@@ -16,8 +16,13 @@ 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')
|
||||
|
||||
type ShellQuoteContext = 'single' | 'double' | null
|
||||
|
||||
export class VariableResolver {
|
||||
private resolvers: Resolver[]
|
||||
private blockResolver: BlockResolver
|
||||
@@ -37,6 +42,67 @@ 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<string, any> | null | undefined,
|
||||
block: SerializedBlock
|
||||
): { resolvedInputs: Record<string, any>; contextVariables: Record<string, unknown> } {
|
||||
const contextVariables: Record<string, unknown> = {}
|
||||
const resolved: Record<string, any> = {}
|
||||
|
||||
if (!params) {
|
||||
return { resolvedInputs: resolved, contextVariables }
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -150,6 +216,170 @@ 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, unknown>
|
||||
): string {
|
||||
const resolutionContext: ResolutionContext = {
|
||||
executionContext: ctx,
|
||||
executionState: this.state,
|
||||
currentNodeId,
|
||||
loopScope,
|
||||
}
|
||||
|
||||
const language = (block.config?.params as Record<string, unknown> | undefined)?.language as
|
||||
| string
|
||||
| undefined
|
||||
|
||||
let replacementError: Error | null = null
|
||||
|
||||
let result = replaceValidReferences(template, (match, index) => {
|
||||
if (replacementError) return match
|
||||
|
||||
try {
|
||||
if (this.blockResolver.canResolve(match)) {
|
||||
const resolved = this.resolveReference(match, resolutionContext)
|
||||
if (resolved === undefined) return match
|
||||
|
||||
const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved
|
||||
|
||||
// Block output: store in contextVarAccumulator and replace the reference
|
||||
// with language-specific runtime access to that stored value.
|
||||
const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
|
||||
contextVarAccumulator[varName] = effectiveValue
|
||||
const replacement = this.formatContextVariableReference(
|
||||
varName,
|
||||
language,
|
||||
template,
|
||||
index,
|
||||
effectiveValue
|
||||
)
|
||||
return replacement
|
||||
}
|
||||
|
||||
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 formatContextVariableReference(
|
||||
varName: string,
|
||||
language: string | undefined,
|
||||
template: string,
|
||||
matchIndex: number,
|
||||
value: unknown
|
||||
): string {
|
||||
if (language === 'python') {
|
||||
return `globals()[${JSON.stringify(varName)}]`
|
||||
}
|
||||
|
||||
if (language === 'shell') {
|
||||
return this.formatShellContextVariableReference(varName, template, matchIndex, value)
|
||||
}
|
||||
|
||||
return `globalThis[${JSON.stringify(varName)}]`
|
||||
}
|
||||
|
||||
private formatShellContextVariableReference(
|
||||
varName: string,
|
||||
template: string,
|
||||
matchIndex: number,
|
||||
value: unknown
|
||||
): string {
|
||||
const expansion = `\${${varName}}`
|
||||
const quoteContext = this.getShellQuoteContext(template, matchIndex)
|
||||
if (quoteContext === 'double') {
|
||||
return expansion
|
||||
}
|
||||
|
||||
const shouldQuote =
|
||||
quoteContext === 'single' ||
|
||||
typeof value === 'string' ||
|
||||
(typeof value === 'object' && value !== null) ||
|
||||
Array.isArray(value)
|
||||
|
||||
if (!shouldQuote) {
|
||||
return expansion
|
||||
}
|
||||
|
||||
const quotedExpansion = `"${expansion}"`
|
||||
if (quoteContext === 'single') {
|
||||
return `'${quotedExpansion}'`
|
||||
}
|
||||
|
||||
return quotedExpansion
|
||||
}
|
||||
|
||||
private getShellQuoteContext(template: string, index: number): ShellQuoteContext {
|
||||
let quoteContext: ShellQuoteContext = null
|
||||
|
||||
for (let i = 0; i < index; i++) {
|
||||
const char = template[i]
|
||||
|
||||
if (quoteContext === null && this.isShellCommentStart(template, i)) {
|
||||
const nextNewline = template.indexOf('\n', i + 1)
|
||||
if (nextNewline === -1 || nextNewline >= index) {
|
||||
break
|
||||
}
|
||||
i = nextNewline
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '\\' && quoteContext !== 'single') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'" && quoteContext !== 'double') {
|
||||
quoteContext = quoteContext === 'single' ? null : 'single'
|
||||
} else if (char === '"' && quoteContext !== 'single') {
|
||||
quoteContext = quoteContext === 'double' ? null : 'double'
|
||||
}
|
||||
}
|
||||
|
||||
return quoteContext
|
||||
}
|
||||
|
||||
private isShellCommentStart(template: string, index: number): boolean {
|
||||
if (template[index] !== '#') {
|
||||
return false
|
||||
}
|
||||
|
||||
const previous = template[index - 1]
|
||||
return previous === undefined || /\s|[;&|()<>]/.test(previous)
|
||||
}
|
||||
|
||||
private resolveTemplate(
|
||||
ctx: ExecutionContext,
|
||||
currentNodeId: string,
|
||||
|
||||
@@ -99,6 +99,7 @@ export const functionExecuteContract = defineRouteContract({
|
||||
blockNameMapping: z.record(z.string(), z.string()).optional().default({}),
|
||||
blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}),
|
||||
workflowVariables: unknownRecordSchema.optional().default({}),
|
||||
contextVariables: unknownRecordSchema.optional().default({}),
|
||||
workflowId: z.string().optional(),
|
||||
workspaceId: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
|
||||
@@ -56,10 +56,17 @@ describe('Function Execute Tool', () => {
|
||||
blockData: {},
|
||||
blockNameMapping: {},
|
||||
blockOutputSchemas: {},
|
||||
contextVariables: {},
|
||||
isCustomTool: false,
|
||||
language: 'javascript',
|
||||
outputFormat: undefined,
|
||||
outputMimeType: undefined,
|
||||
outputPath: undefined,
|
||||
outputSandboxPath: undefined,
|
||||
outputTable: undefined,
|
||||
timeout: 5000,
|
||||
workflowId: undefined,
|
||||
workspaceId: undefined,
|
||||
userId: undefined,
|
||||
})
|
||||
})
|
||||
@@ -85,9 +92,16 @@ describe('Function Execute Tool', () => {
|
||||
blockData: {},
|
||||
blockNameMapping: {},
|
||||
blockOutputSchemas: {},
|
||||
contextVariables: {},
|
||||
isCustomTool: false,
|
||||
language: 'javascript',
|
||||
outputFormat: undefined,
|
||||
outputMimeType: undefined,
|
||||
outputPath: undefined,
|
||||
outputSandboxPath: undefined,
|
||||
outputTable: undefined,
|
||||
workflowId: undefined,
|
||||
workspaceId: undefined,
|
||||
userId: undefined,
|
||||
})
|
||||
})
|
||||
@@ -105,9 +119,16 @@ describe('Function Execute Tool', () => {
|
||||
blockData: {},
|
||||
blockNameMapping: {},
|
||||
blockOutputSchemas: {},
|
||||
contextVariables: {},
|
||||
isCustomTool: false,
|
||||
language: 'javascript',
|
||||
outputFormat: undefined,
|
||||
outputMimeType: undefined,
|
||||
outputPath: undefined,
|
||||
outputSandboxPath: undefined,
|
||||
outputTable: undefined,
|
||||
workflowId: undefined,
|
||||
workspaceId: undefined,
|
||||
userId: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
|
||||
blockData: params.blockData || {},
|
||||
blockNameMapping: params.blockNameMapping || {},
|
||||
blockOutputSchemas: params.blockOutputSchemas || {},
|
||||
contextVariables: params.contextVariables || {},
|
||||
workflowId: params._context?.workflowId,
|
||||
userId: params._context?.userId,
|
||||
workspaceId: params._context?.workspaceId,
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface CodeExecutionInput {
|
||||
blockData?: Record<string, unknown>
|
||||
blockNameMapping?: Record<string, string>
|
||||
blockOutputSchemas?: Record<string, Record<string, unknown>>
|
||||
/** Pre-resolved block output variables from the executor, injected as VM globals. */
|
||||
contextVariables?: Record<string, unknown>
|
||||
_context?: {
|
||||
workflowId?: string
|
||||
userId?: string
|
||||
|
||||
Reference in New Issue
Block a user