mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(executor): reserved keyword errors (#4482)
* improvment(executor): reserved keyword errors * address comments and make error messages for func execute make sense block ref accs
This commit is contained in:
@@ -538,6 +538,36 @@ describe('Function Execute API Route', () => {
|
||||
expect(data.error).toContain('undefinedVariable is not defined')
|
||||
})
|
||||
|
||||
it('should show original source code when resolved block references cause syntax errors', async () => {
|
||||
mockExecuteInIsolatedVM.mockResolvedValueOnce({
|
||||
result: null,
|
||||
stdout: '',
|
||||
error: {
|
||||
message: 'Unexpected identifier "globalThis"',
|
||||
name: 'SyntaxError',
|
||||
line: 1,
|
||||
column: 7,
|
||||
lineContent: 'retur globalThis["__blockRef_0"]',
|
||||
},
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'retur globalThis["__blockRef_0"]',
|
||||
sourceCode: 'retur <start.reqerror>',
|
||||
contextVariables: { __blockRef_0: 'value' },
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(422)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Line 1: `retur <start.reqerror>`')
|
||||
expect(data.error).not.toContain('globalThis')
|
||||
expect(data.debug.lineContent).toBe('retur <start.reqerror>')
|
||||
})
|
||||
|
||||
it('should handle thrown errors gracefully', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'throw new Error("Custom error message");',
|
||||
|
||||
@@ -410,6 +410,30 @@ function createUserFriendlyErrorMessage(
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
function getErrorDisplayCode(sourceCode: string | undefined, resolvedCode: string): string {
|
||||
return sourceCode && sourceCode.length > 0 ? sourceCode : resolvedCode
|
||||
}
|
||||
|
||||
function getLineContent(code: string, line: number | undefined): string | undefined {
|
||||
if (line === undefined || line < 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return code.split('\n')[line - 1]?.trim()
|
||||
}
|
||||
|
||||
function getErrorDisplayMessage(
|
||||
message: string,
|
||||
sourceCode: string | undefined,
|
||||
resolvedCode: string
|
||||
): string {
|
||||
if (!sourceCode || sourceCode === resolvedCode || !resolvedCode.includes('__blockRef_')) {
|
||||
return message
|
||||
}
|
||||
|
||||
return message.replace(/\s+["']globalThis["']/g, '')
|
||||
}
|
||||
|
||||
function resolveWorkflowVariables(
|
||||
code: string,
|
||||
workflowVariables: Record<string, any>,
|
||||
@@ -767,6 +791,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
let stdout = ''
|
||||
let userCodeStartLine = 3 // Default value for error reporting
|
||||
let resolvedCode = '' // Store resolved code for error reporting
|
||||
let sourceCodeForErrors: string | undefined
|
||||
|
||||
try {
|
||||
const auth = await checkInternalAuth(req)
|
||||
@@ -783,6 +808,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
|
||||
const {
|
||||
code,
|
||||
sourceCode,
|
||||
params = {},
|
||||
timeout = DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
language = DEFAULT_CODE_LANGUAGE,
|
||||
@@ -801,6 +827,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
isCustomTool = false,
|
||||
_sandboxFiles,
|
||||
} = body
|
||||
sourceCodeForErrors = sourceCode
|
||||
|
||||
const executionParams = { ...params }
|
||||
executionParams._context = undefined
|
||||
@@ -1019,11 +1046,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
})
|
||||
|
||||
if (e2bError) {
|
||||
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode),
|
||||
e2bStdout,
|
||||
lang,
|
||||
resolvedCode,
|
||||
errorDisplayCode,
|
||||
prologueLineCount + importLineCount
|
||||
)
|
||||
return NextResponse.json(
|
||||
@@ -1101,11 +1129,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
})
|
||||
|
||||
if (e2bError) {
|
||||
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
||||
const { formattedError, cleanedOutput } = formatE2BError(
|
||||
e2bError,
|
||||
getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode),
|
||||
e2bStdout,
|
||||
lang,
|
||||
resolvedCode,
|
||||
errorDisplayCode,
|
||||
prologueLineCount
|
||||
)
|
||||
return NextResponse.json(
|
||||
@@ -1192,13 +1221,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
let adjustedLineContent = ivmError.lineContent
|
||||
if (prependedLineCount > 0 && ivmError.line !== undefined) {
|
||||
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
|
||||
const codeLines = resolvedCode.split('\n')
|
||||
if (adjustedLine <= codeLines.length) {
|
||||
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
|
||||
}
|
||||
}
|
||||
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
||||
const displayMessage = getErrorDisplayMessage(
|
||||
ivmError.message,
|
||||
sourceCodeForErrors,
|
||||
resolvedCode
|
||||
)
|
||||
adjustedLineContent = getLineContent(errorDisplayCode, adjustedLine) ?? adjustedLineContent
|
||||
const enhancedError: EnhancedError = {
|
||||
message: ivmError.message,
|
||||
message: displayMessage,
|
||||
name: ivmError.name,
|
||||
stack: ivmError.stack,
|
||||
originalError: ivmError,
|
||||
@@ -1210,7 +1242,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
||||
enhancedError,
|
||||
requestId,
|
||||
resolvedCode
|
||||
errorDisplayCode
|
||||
)
|
||||
|
||||
const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
|
||||
@@ -1261,11 +1293,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
|
||||
executionTime,
|
||||
})
|
||||
|
||||
const enhancedError = extractEnhancedError(error, userCodeStartLine, resolvedCode)
|
||||
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
|
||||
const enhancedError = extractEnhancedError(error, userCodeStartLine, errorDisplayCode)
|
||||
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
||||
enhancedError,
|
||||
requestId,
|
||||
resolvedCode
|
||||
errorDisplayCode
|
||||
)
|
||||
|
||||
logger.error(`[${requestId}] Enhanced error details`, {
|
||||
|
||||
@@ -196,6 +196,25 @@ describe('FunctionBlockHandler', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass original function code for error display after reference resolution', async () => {
|
||||
mockBlock.config.params = { code: 'retur <start.reqerror>' }
|
||||
|
||||
await handler.execute(mockContext, mockBlock, {
|
||||
code: 'retur globalThis["__blockRef_0"]',
|
||||
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: { __blockRef_0: 'value' },
|
||||
})
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'function_execute',
|
||||
expect.objectContaining({
|
||||
code: 'retur globalThis["__blockRef_0"]',
|
||||
sourceCode: 'retur <start.reqerror>',
|
||||
}),
|
||||
false,
|
||||
mockContext
|
||||
)
|
||||
})
|
||||
|
||||
it('should normalize malformed execution context records before calling function_execute', async () => {
|
||||
const legacyVariable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
|
||||
mockContext.workflowVariables = [legacyVariable] as unknown as Record<string, any>
|
||||
|
||||
@@ -12,6 +12,22 @@ import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
function readCodeContent(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) =>
|
||||
entry && typeof entry === 'object' && typeof entry.content === 'string' ? entry.content : ''
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Function blocks that execute custom code.
|
||||
*/
|
||||
@@ -25,37 +41,36 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
block: SerializedBlock,
|
||||
inputs: Record<string, any>
|
||||
): Promise<any> {
|
||||
const codeContent = Array.isArray(inputs.code)
|
||||
? inputs.code.map((c: { content: string }) => c.content).join('\n')
|
||||
: inputs.code
|
||||
const codeContent = readCodeContent(inputs.code) ?? inputs.code
|
||||
const sourceCode = readCodeContent(
|
||||
(block.config?.params as Record<string, unknown> | undefined)?.code
|
||||
)
|
||||
|
||||
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
|
||||
|
||||
const contextVariables = normalizeRecord(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY])
|
||||
|
||||
const result = await executeTool(
|
||||
'function_execute',
|
||||
{
|
||||
code: codeContent,
|
||||
language: inputs.language || DEFAULT_CODE_LANGUAGE,
|
||||
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
envVars: normalizeStringRecord(ctx.environmentVariables),
|
||||
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
contextVariables,
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
const toolParams = {
|
||||
code: codeContent,
|
||||
...(sourceCode ? { sourceCode } : {}),
|
||||
language: inputs.language || DEFAULT_CODE_LANGUAGE,
|
||||
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
envVars: normalizeStringRecord(ctx.environmentVariables),
|
||||
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
blockOutputSchemas,
|
||||
contextVariables,
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
false,
|
||||
ctx
|
||||
)
|
||||
}
|
||||
|
||||
const result = await executeTool('function_execute', toolParams, false, ctx)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Function execution failed')
|
||||
|
||||
@@ -213,6 +213,15 @@ export interface NormalizedBlockOutput {
|
||||
_pauseMetadata?: PauseMetadata
|
||||
}
|
||||
|
||||
export const EXECUTION_CONTROL_OUTPUT_FIELD_NAMES = [
|
||||
'error',
|
||||
'selectedOption',
|
||||
'selectedRoute',
|
||||
'_pauseMetadata',
|
||||
] as const
|
||||
|
||||
export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_FIELD_NAMES)[number]
|
||||
|
||||
export interface BlockLog {
|
||||
blockId: string
|
||||
blockName?: string
|
||||
|
||||
@@ -119,6 +119,167 @@ describe('start-block utilities', () => {
|
||||
expect(output.files).toEqual(files)
|
||||
})
|
||||
|
||||
it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 'error', type: 'string' },
|
||||
{ name: 'error', type: 'string' },
|
||||
{ name: ' selectedOption ', type: 'string' },
|
||||
{ name: 'selectedRoute', type: 'string' },
|
||||
{ name: '_pauseMetadata', type: 'object' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { error: false, selectedRoute: 'source' },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-start_trigger" cannot use reserved input format field name(s): error, selectedOption, selectedRoute, _pauseMetadata'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'rejects reserved top-level runtime input keys copied to unified Start output',
|
||||
() => {
|
||||
const block = createBlock('start_trigger', 'start')
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { error: 'false', payload: 'value' },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-start_trigger" cannot use reserved runtime input field name(s): error'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('rejects reserved nested API input keys copied to trigger output', () => {
|
||||
const block = createBlock('api_trigger', 'api')
|
||||
const resolution = {
|
||||
blockId: 'api',
|
||||
block,
|
||||
path: StartBlockPath.SPLIT_API,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: { selectedRoute: 'route-1', payload: 'value' } },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-api_trigger" cannot use reserved runtime input field name(s): selectedRoute'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('allows reserved inputFormat field names on split chat trigger output', () => {
|
||||
const block = createBlock('chat_trigger', 'chat', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [{ name: 'error', type: 'string' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolution = {
|
||||
blockId: 'chat',
|
||||
block,
|
||||
path: StartBlockPath.SPLIT_CHAT,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello', conversationId: 'conversation-1' },
|
||||
})
|
||||
|
||||
expect(output).toEqual({ input: 'hello', conversationId: 'conversation-1' })
|
||||
})
|
||||
|
||||
it.concurrent('allows reserved inputFormat field names on legacy chat starter output', () => {
|
||||
const block = createBlock('starter', 'starter', {
|
||||
subBlocks: {
|
||||
startWorkflow: { value: 'chat' },
|
||||
inputFormat: {
|
||||
value: [{ name: 'error', type: 'string' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolution = {
|
||||
blockId: 'starter',
|
||||
block,
|
||||
path: StartBlockPath.LEGACY_STARTER,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello' },
|
||||
})
|
||||
|
||||
expect(output).toEqual({ input: 'hello' })
|
||||
})
|
||||
|
||||
it.concurrent('allows reserved inputFormat field names on serialized legacy chat starter', () => {
|
||||
const block = createBlock('starter', 'starter')
|
||||
block.config.params = {
|
||||
startWorkflow: 'chat',
|
||||
inputFormat: [{ name: 'error', type: 'string' }],
|
||||
}
|
||||
const resolution = {
|
||||
blockId: 'starter',
|
||||
block,
|
||||
path: StartBlockPath.LEGACY_STARTER,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { input: 'hello' },
|
||||
})
|
||||
|
||||
expect(output).toEqual({ input: 'hello' })
|
||||
})
|
||||
|
||||
it.concurrent('ignores malformed non-string inputFormat field names', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
subBlocks: {
|
||||
inputFormat: {
|
||||
value: [
|
||||
{ name: 123, type: 'string', value: 'ignored' },
|
||||
{ name: 'customField', type: 'string' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.UNIFIED,
|
||||
} as const
|
||||
|
||||
const output = buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { customField: 'value' },
|
||||
})
|
||||
|
||||
expect(output.customField).toBe('value')
|
||||
expect(output[123]).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('inputFormat default values', () => {
|
||||
it.concurrent('uses default value when runtime does not provide the field', () => {
|
||||
const block = createBlock('start_trigger', 'start', {
|
||||
@@ -294,6 +455,24 @@ describe('start-block utilities', () => {
|
||||
})
|
||||
|
||||
describe('EXTERNAL_TRIGGER path', () => {
|
||||
it.concurrent('rejects reserved runtime input keys copied to external trigger output', () => {
|
||||
const block = createBlock('webhook', 'start')
|
||||
const resolution = {
|
||||
blockId: 'start',
|
||||
block,
|
||||
path: StartBlockPath.EXTERNAL_TRIGGER,
|
||||
} as const
|
||||
|
||||
expect(() =>
|
||||
buildStartBlockOutput({
|
||||
resolution,
|
||||
workflowInput: { _pauseMetadata: { contextId: 'fake-pause' }, payload: 'value' },
|
||||
})
|
||||
).toThrow(
|
||||
'Start block "block-webhook" cannot use reserved runtime input field name(s): _pauseMetadata'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('preserves coerced types for integration trigger payload', () => {
|
||||
const block = createBlock('webhook', 'start', {
|
||||
subBlocks: {
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
|
||||
import {
|
||||
classifyStartBlockType,
|
||||
getLegacyStarterMode,
|
||||
resolveStartCandidates,
|
||||
StartBlockPath,
|
||||
} from '@/lib/workflows/triggers/triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { NormalizedBlockOutput, UserFile } from '@/executor/types'
|
||||
import {
|
||||
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES,
|
||||
type NormalizedBlockOutput,
|
||||
type UserFile,
|
||||
} from '@/executor/types'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { safeAssign } from '@/tools/safe-assign'
|
||||
|
||||
type ExecutionKind = 'chat' | 'manual' | 'api' | 'external'
|
||||
|
||||
const EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET = new Set<string>(
|
||||
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES
|
||||
)
|
||||
|
||||
export interface ExecutorStartResolution {
|
||||
blockId: string
|
||||
block: SerializedBlock
|
||||
@@ -133,6 +140,92 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] {
|
||||
.map((field) => field)
|
||||
}
|
||||
|
||||
function normalizeLegacyStarterMode(modeValue: unknown): 'manual' | 'api' | 'chat' | null {
|
||||
if (modeValue === 'chat') return 'chat'
|
||||
if (modeValue === 'api' || modeValue === 'run') return 'api'
|
||||
if (modeValue === undefined || modeValue === 'manual') return 'manual'
|
||||
return null
|
||||
}
|
||||
|
||||
function getSerializedLegacyStarterMode(block: SerializedBlock): 'manual' | 'api' | 'chat' | null {
|
||||
const fromMetadata = readMetadataSubBlockValue(block, 'startWorkflow')
|
||||
if (fromMetadata !== undefined) {
|
||||
return normalizeLegacyStarterMode(fromMetadata)
|
||||
}
|
||||
|
||||
return normalizeLegacyStarterMode(block.config?.params?.startWorkflow)
|
||||
}
|
||||
|
||||
function readInputFormatFieldName(field: InputFormatField): string | undefined {
|
||||
return typeof field.name === 'string' ? field.name.trim() : undefined
|
||||
}
|
||||
|
||||
function collectExecutionControlFieldNames(fieldNames: Iterable<string | undefined>): string[] {
|
||||
const reservedFieldNames = new Set<string>()
|
||||
|
||||
for (const fieldName of fieldNames) {
|
||||
if (fieldName && EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) {
|
||||
reservedFieldNames.add(fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reservedFieldNames)
|
||||
}
|
||||
|
||||
function throwReservedStartOutputFieldsError(
|
||||
block: SerializedBlock,
|
||||
reservedFieldNames: string[],
|
||||
source: 'input format' | 'runtime input'
|
||||
): never {
|
||||
const blockName = block.metadata?.name ?? block.id
|
||||
|
||||
throw new Error(
|
||||
`Start block "${blockName}" cannot use reserved ${source} field name(s): ${reservedFieldNames.join(', ')}. These names control workflow execution and cannot be used as Start outputs. Rename these fields before running the workflow. Reserved names are: ${EXECUTION_CONTROL_OUTPUT_FIELD_NAMES.join(', ')}.`
|
||||
)
|
||||
}
|
||||
|
||||
function assertNoReservedInputFormatFields(
|
||||
inputFormat: InputFormatField[],
|
||||
block: SerializedBlock
|
||||
): void {
|
||||
const reservedFieldNames = collectExecutionControlFieldNames(
|
||||
inputFormat.map(readInputFormatFieldName)
|
||||
)
|
||||
|
||||
if (reservedFieldNames.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'input format')
|
||||
}
|
||||
|
||||
function assertNoReservedStartOutputFields(
|
||||
output: NormalizedBlockOutput,
|
||||
block: SerializedBlock
|
||||
): void {
|
||||
const reservedFieldNames = collectExecutionControlFieldNames(Object.keys(output))
|
||||
|
||||
if (reservedFieldNames.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'runtime input')
|
||||
}
|
||||
|
||||
function pathConsumesInputFormat(
|
||||
path: StartBlockPath,
|
||||
legacyStarterMode: 'manual' | 'api' | 'chat' | null
|
||||
): boolean {
|
||||
switch (path) {
|
||||
case StartBlockPath.SPLIT_CHAT:
|
||||
return false
|
||||
case StartBlockPath.LEGACY_STARTER:
|
||||
return legacyStarterMode !== 'chat'
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function coerceValue(type: string | null | undefined, value: unknown): unknown {
|
||||
if (value === undefined || value === null) {
|
||||
return value
|
||||
@@ -190,7 +283,7 @@ function deriveInputFromFormat(
|
||||
}
|
||||
|
||||
for (const field of inputFormat) {
|
||||
const fieldName = field.name?.trim()
|
||||
const fieldName = readInputFormatFieldName(field)
|
||||
if (!fieldName) continue
|
||||
|
||||
let fieldValue: unknown
|
||||
@@ -436,35 +529,52 @@ export interface StartBlockOutputOptions {
|
||||
export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput {
|
||||
const { resolution, workflowInput } = options
|
||||
const inputFormat = extractInputFormat(resolution.block)
|
||||
const legacyStarterMode =
|
||||
resolution.path === StartBlockPath.LEGACY_STARTER
|
||||
? getSerializedLegacyStarterMode(resolution.block)
|
||||
: null
|
||||
|
||||
if (pathConsumesInputFormat(resolution.path, legacyStarterMode)) {
|
||||
assertNoReservedInputFormatFields(inputFormat, resolution.block)
|
||||
}
|
||||
|
||||
const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat(
|
||||
inputFormat,
|
||||
workflowInput
|
||||
)
|
||||
|
||||
let output: NormalizedBlockOutput
|
||||
|
||||
switch (resolution.path) {
|
||||
case StartBlockPath.UNIFIED:
|
||||
return buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
|
||||
output = buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
|
||||
break
|
||||
|
||||
case StartBlockPath.SPLIT_API:
|
||||
case StartBlockPath.SPLIT_INPUT:
|
||||
return buildApiOrInputOutput(finalInput, workflowInput)
|
||||
output = buildApiOrInputOutput(finalInput, workflowInput)
|
||||
break
|
||||
|
||||
case StartBlockPath.SPLIT_CHAT:
|
||||
return buildChatOutput(workflowInput)
|
||||
output = buildChatOutput(workflowInput)
|
||||
break
|
||||
|
||||
case StartBlockPath.SPLIT_MANUAL:
|
||||
return buildManualTriggerOutput(finalInput, workflowInput)
|
||||
output = buildManualTriggerOutput(finalInput, workflowInput)
|
||||
break
|
||||
|
||||
case StartBlockPath.EXTERNAL_TRIGGER:
|
||||
return buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
|
||||
output = buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
|
||||
break
|
||||
|
||||
case StartBlockPath.LEGACY_STARTER:
|
||||
return buildLegacyStarterOutput(
|
||||
finalInput,
|
||||
workflowInput,
|
||||
getLegacyStarterMode({ subBlocks: extractSubBlocks(resolution.block) })
|
||||
)
|
||||
output = buildLegacyStarterOutput(finalInput, workflowInput, legacyStarterMode)
|
||||
break
|
||||
|
||||
default:
|
||||
return buildManualTriggerOutput(finalInput, workflowInput)
|
||||
output = buildManualTriggerOutput(finalInput, workflowInput)
|
||||
}
|
||||
|
||||
assertNoReservedStartOutputFields(output, resolution.block)
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export const functionExecuteContract = defineRouteContract({
|
||||
path: '/api/function/execute',
|
||||
body: z.object({
|
||||
code: z.string().min(1, 'Code is required'),
|
||||
sourceCode: z.string().optional(),
|
||||
params: unknownRecordSchema.optional().default({}),
|
||||
timeout: z.coerce.number().int().positive().optional(),
|
||||
language: z.string().optional().default(DEFAULT_CODE_LANGUAGE),
|
||||
|
||||
@@ -122,6 +122,7 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
code: codeContent,
|
||||
sourceCode: params.sourceCode,
|
||||
language: params.language || DEFAULT_CODE_LANGUAGE,
|
||||
timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
|
||||
outputPath: params.outputPath,
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface CodeExecutionInput {
|
||||
code: Array<{ content: string; id: string }> | string
|
||||
/** Original user-authored code used for error display after execution-time reference resolution. */
|
||||
sourceCode?: string
|
||||
language?: CodeLanguage
|
||||
useLocalVM?: boolean
|
||||
timeout?: number
|
||||
|
||||
Reference in New Issue
Block a user