diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 676397d2d2..f874c6304b 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -21,7 +21,6 @@ export async function POST(req: NextRequest) { const requestId = generateRequestId() try { - // Get user session const session = await getSession() if (!session?.user?.email) { logger.warn(`[${requestId}] Unauthorized help request attempt`) @@ -30,20 +29,20 @@ export async function POST(req: NextRequest) { const email = session.user.email - // Handle multipart form data const formData = await req.formData() - // Extract form fields const subject = formData.get('subject') as string const message = formData.get('message') as string const type = formData.get('type') as string + const workflowId = formData.get('workflowId') as string | null + const workspaceId = formData.get('workspaceId') as string + const userAgent = formData.get('userAgent') as string | null logger.info(`[${requestId}] Processing help request`, { type, email: `${email.substring(0, 3)}***`, // Log partial email for privacy }) - // Validate the form data const validationResult = helpFormSchema.safeParse({ subject, message, @@ -60,7 +59,6 @@ export async function POST(req: NextRequest) { ) } - // Extract images const images: { filename: string; content: Buffer; contentType: string }[] = [] for (const [key, value] of formData.entries()) { @@ -81,10 +79,14 @@ export async function POST(req: NextRequest) { logger.debug(`[${requestId}] Help request includes ${images.length} images`) - // Prepare email content + const userId = session.user.id let emailText = ` Type: ${type} From: ${email} +User ID: ${userId} +Workspace ID: ${workspaceId ?? 'N/A'} +Workflow ID: ${workflowId ?? 'N/A'} +Browser: ${userAgent ?? 'N/A'} ${message} ` @@ -115,7 +117,6 @@ ${message} logger.info(`[${requestId}] Help request email sent successfully`) - // Send confirmation email to the user try { const confirmationHtml = await renderHelpConfirmationEmail( type as 'bug' | 'feedback' | 'feature_request' | 'other', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx index e8d82c61c8..a29e542f36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/help-modal/help-modal.tsx @@ -57,9 +57,11 @@ interface ImageWithPreview extends File { interface HelpModalProps { open: boolean onOpenChange: (open: boolean) => void + workflowId?: string + workspaceId: string } -export function HelpModal({ open, onOpenChange }: HelpModalProps) { +export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpModalProps) { const fileInputRef = useRef(null) const scrollContainerRef = useRef(null) @@ -370,18 +372,20 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { setSubmitStatus(null) try { - // Prepare form data with images const formData = new FormData() formData.append('subject', data.subject) formData.append('message', data.message) formData.append('type', data.type) + formData.append('workspaceId', workspaceId) + formData.append('userAgent', navigator.userAgent) + if (workflowId) { + formData.append('workflowId', workflowId) + } - // Attach all images to form data images.forEach((image, index) => { formData.append(`image_${index}`, image) }) - // Submit to API const response = await fetch('/api/help', { method: 'POST', body: formData, @@ -392,11 +396,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { throw new Error(errorData.error || 'Failed to submit help request') } - // Handle success setSubmitStatus('success') reset() - // Clean up resources images.forEach((image) => URL.revokeObjectURL(image.preview)) setImages([]) } catch (error) { @@ -406,7 +408,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { setIsSubmitting(false) } }, - [images, reset] + [images, reset, workflowId, workspaceId] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 13fd4aa42f..09c3f75a36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -661,7 +661,12 @@ export function Sidebar() { /> {/* Footer Navigation Modals */} - + (open ? openSettingsModal() : closeSettingsModal())} diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 1a0fedf40a..d8c08430fb 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -25,6 +25,7 @@ import { MODELS_WITH_TEMPERATURE_SUPPORT, MODELS_WITH_VERBOSITY, PROVIDERS_WITH_TOOL_USAGE_CONTROL, + prepareToolExecution, prepareToolsWithUsageControl, shouldBillModelUsage, supportsTemperature, @@ -979,6 +980,245 @@ describe('Tool Management', () => { }) }) +describe('prepareToolExecution', () => { + describe('basic parameter merging', () => { + it.concurrent('should merge LLM args with user params', () => { + const tool = { + params: { apiKey: 'user-key', channel: '#general' }, + } + const llmArgs = { message: 'Hello world', channel: '#random' } + const request = { workflowId: 'wf-123' } + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + expect(toolParams.apiKey).toBe('user-key') + expect(toolParams.channel).toBe('#general') // User value wins + expect(toolParams.message).toBe('Hello world') + }) + + it.concurrent('should filter out empty string user params', () => { + const tool = { + params: { apiKey: 'user-key', channel: '' }, // Empty channel + } + const llmArgs = { message: 'Hello', channel: '#llm-channel' } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + expect(toolParams.apiKey).toBe('user-key') + expect(toolParams.channel).toBe('#llm-channel') // LLM value used since user is empty + expect(toolParams.message).toBe('Hello') + }) + }) + + describe('inputMapping deep merge for workflow tools', () => { + it.concurrent('should deep merge inputMapping when user provides empty object', () => { + const tool = { + params: { + workflowId: 'child-workflow-123', + inputMapping: '{}', // Empty JSON string from UI + }, + } + const llmArgs = { + inputMapping: { query: 'search term', limit: 10 }, + } + const request = { workflowId: 'parent-workflow' } + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + // LLM values should be used since user object is empty + expect(toolParams.inputMapping).toEqual({ query: 'search term', limit: 10 }) + expect(toolParams.workflowId).toBe('child-workflow-123') + }) + + it.concurrent('should deep merge inputMapping with partial user values', () => { + const tool = { + params: { + workflowId: 'child-workflow', + inputMapping: '{"query": "", "customField": "user-value"}', // Partial values + }, + } + const llmArgs = { + inputMapping: { query: 'llm-search', limit: 10 }, + } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + // LLM fills empty query, user's customField preserved, LLM's limit included + expect(toolParams.inputMapping).toEqual({ + query: 'llm-search', + limit: 10, + customField: 'user-value', + }) + }) + + it.concurrent('should preserve non-empty user inputMapping values', () => { + const tool = { + params: { + workflowId: 'child-workflow', + inputMapping: '{"query": "user-search", "limit": 5}', + }, + } + const llmArgs = { + inputMapping: { query: 'llm-search', limit: 10, extra: 'field' }, + } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + // User values win, but LLM's extra field is included + expect(toolParams.inputMapping).toEqual({ + query: 'user-search', + limit: 5, + extra: 'field', + }) + }) + + it.concurrent('should handle inputMapping as object (not JSON string)', () => { + const tool = { + params: { + workflowId: 'child-workflow', + inputMapping: { query: '', customField: 'user-value' }, // Object, not string + }, + } + const llmArgs = { + inputMapping: { query: 'llm-search', limit: 10 }, + } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + expect(toolParams.inputMapping).toEqual({ + query: 'llm-search', + limit: 10, + customField: 'user-value', + }) + }) + + it.concurrent('should use LLM inputMapping when user does not provide it', () => { + const tool = { + params: { workflowId: 'child-workflow' }, // No inputMapping + } + const llmArgs = { + inputMapping: { query: 'llm-search', limit: 10 }, + } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + expect(toolParams.inputMapping).toEqual({ query: 'llm-search', limit: 10 }) + }) + + it.concurrent('should use user inputMapping when LLM does not provide it', () => { + const tool = { + params: { + workflowId: 'child-workflow', + inputMapping: '{"query": "user-search"}', + }, + } + const llmArgs = {} // No inputMapping from LLM + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + expect(toolParams.inputMapping).toEqual({ query: 'user-search' }) + }) + + it.concurrent('should handle invalid JSON in user inputMapping gracefully', () => { + const tool = { + params: { + workflowId: 'child-workflow', + inputMapping: 'not valid json {', + }, + } + const llmArgs = { + inputMapping: { query: 'llm-search' }, + } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + // Should use LLM values since user JSON is invalid + expect(toolParams.inputMapping).toEqual({ query: 'llm-search' }) + }) + + it.concurrent('should not affect other parameters - normal override behavior', () => { + const tool = { + params: { apiKey: 'user-key', channel: '#general' }, + } + const llmArgs = { message: 'Hello', channel: '#random' } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + // Normal behavior: user values override LLM values + expect(toolParams.apiKey).toBe('user-key') + expect(toolParams.channel).toBe('#general') // User value wins + expect(toolParams.message).toBe('Hello') + }) + + it.concurrent('should preserve 0 and false as valid user values in inputMapping', () => { + const tool = { + params: { + workflowId: 'child-workflow', + inputMapping: '{"limit": 0, "enabled": false, "query": ""}', + }, + } + const llmArgs = { + inputMapping: { limit: 10, enabled: true, query: 'llm-search' }, + } + const request = {} + + const { toolParams } = prepareToolExecution(tool, llmArgs, request) + + // 0 and false should be preserved (they're valid values) + // empty string should be filled by LLM + expect(toolParams.inputMapping).toEqual({ + limit: 0, + enabled: false, + query: 'llm-search', + }) + }) + }) + + describe('execution params context', () => { + it.concurrent('should include workflow context in executionParams', () => { + const tool = { params: { message: 'test' } } + const llmArgs = {} + const request = { + workflowId: 'wf-123', + workspaceId: 'ws-456', + chatId: 'chat-789', + userId: 'user-abc', + } + + const { executionParams } = prepareToolExecution(tool, llmArgs, request) + + expect(executionParams._context).toEqual({ + workflowId: 'wf-123', + workspaceId: 'ws-456', + chatId: 'chat-789', + userId: 'user-abc', + }) + }) + + it.concurrent('should include environment and workflow variables', () => { + const tool = { params: {} } + const llmArgs = {} + const request = { + environmentVariables: { API_KEY: 'secret' }, + workflowVariables: { counter: 42 }, + } + + const { executionParams } = prepareToolExecution(tool, llmArgs, request) + + expect(executionParams.envVars).toEqual({ API_KEY: 'secret' }) + expect(executionParams.workflowVariables).toEqual({ counter: 42 }) + }) + }) +}) + describe('Provider/Model Blacklist', () => { describe('isProviderBlacklisted', () => { it.concurrent('should return false when no providers are blacklisted', () => { diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1826bb40cd..73d08735d1 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -30,6 +30,7 @@ import { import type { ProviderId, ProviderToolConfig } from '@/providers/types' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useProvidersStore } from '@/stores/providers/store' +import { deepMergeInputMapping } from '@/tools/params' const logger = createLogger('ProviderUtils') @@ -973,7 +974,7 @@ export function prepareToolExecution( llmArgs: Record, request: { workflowId?: string - workspaceId?: string // Add workspaceId for MCP tools + workspaceId?: string chatId?: string userId?: string environmentVariables?: Record @@ -994,9 +995,24 @@ export function prepareToolExecution( } } - const toolParams = { - ...llmArgs, - ...filteredUserParams, + // Start with LLM params as base + const toolParams: Record = { ...llmArgs } + + // Apply user params with special handling for inputMapping + for (const [key, userValue] of Object.entries(filteredUserParams)) { + if (key === 'inputMapping') { + // Deep merge inputMapping so LLM values fill in empty user fields + const llmInputMapping = llmArgs.inputMapping as Record | undefined + toolParams.inputMapping = deepMergeInputMapping(llmInputMapping, userValue) + } else { + // Normal override for other params + toolParams[key] = userValue + } + } + + // If LLM provided inputMapping but user didn't, ensure it's included + if (llmArgs.inputMapping && !filteredUserParams.inputMapping) { + toolParams.inputMapping = llmArgs.inputMapping } const executionParams = { diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index c026a4d2d2..87326fc329 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -296,6 +296,253 @@ describe('Tool Parameters Utils', () => { }) }) + describe('workflow_executor inputMapping handling', () => { + const mockWorkflowExecutorConfig = { + id: 'workflow_executor', + name: 'Workflow Executor', + description: 'Execute another workflow', + version: '1.0.0', + params: { + workflowId: { + type: 'string', + required: true, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'The ID of the workflow to execute', + }, + inputMapping: { + type: 'object', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Map inputs to the selected workflow', + }, + }, + request: { + url: 'https://api.example.com/workflows', + method: 'POST' as HttpMethod, + headers: () => ({}), + }, + } + + describe('createLLMToolSchema - inputMapping always included', () => { + it.concurrent( + 'should include inputMapping in schema even when user provides empty object', + async () => { + const userProvidedParams = { + workflowId: 'workflow-123', + inputMapping: '{}', + } + + const schema = await createLLMToolSchema(mockWorkflowExecutorConfig, userProvidedParams) + + expect(schema.properties).toHaveProperty('inputMapping') + expect(schema.properties.inputMapping.type).toBe('object') + } + ) + + it.concurrent( + 'should include inputMapping in schema even when user provides object with empty values', + async () => { + const userProvidedParams = { + workflowId: 'workflow-123', + inputMapping: '{"query": "", "limit": ""}', + } + + const schema = await createLLMToolSchema(mockWorkflowExecutorConfig, userProvidedParams) + + expect(schema.properties).toHaveProperty('inputMapping') + } + ) + + it.concurrent( + 'should include inputMapping when user has not provided it at all', + async () => { + const userProvidedParams = { + workflowId: 'workflow-123', + } + + const schema = await createLLMToolSchema(mockWorkflowExecutorConfig, userProvidedParams) + + expect(schema.properties).toHaveProperty('inputMapping') + } + ) + + it.concurrent('should exclude workflowId from schema when user provides it', async () => { + const userProvidedParams = { + workflowId: 'workflow-123', + } + + const schema = await createLLMToolSchema(mockWorkflowExecutorConfig, userProvidedParams) + + expect(schema.properties).not.toHaveProperty('workflowId') + expect(schema.properties).toHaveProperty('inputMapping') + }) + }) + + describe('mergeToolParameters - inputMapping deep merge', () => { + it.concurrent('should deep merge inputMapping when user provides empty object', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: '{}', + } + const llmGenerated = { + inputMapping: { query: 'search term', limit: 10 }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ query: 'search term', limit: 10 }) + expect(merged.workflowId).toBe('workflow-123') + }) + + it.concurrent('should deep merge inputMapping when user provides partial values', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: '{"query": "", "customField": "user-value"}', + } + const llmGenerated = { + inputMapping: { query: 'llm-search', limit: 10 }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ + query: 'llm-search', + limit: 10, + customField: 'user-value', + }) + }) + + it.concurrent('should preserve user inputMapping values when they are non-empty', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: '{"query": "user-search", "limit": 5}', + } + const llmGenerated = { + inputMapping: { query: 'llm-search', limit: 10, extra: 'field' }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ + query: 'user-search', + limit: 5, + extra: 'field', + }) + }) + + it.concurrent('should handle inputMapping as object (not JSON string)', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: { query: '', customField: 'user-value' }, + } + const llmGenerated = { + inputMapping: { query: 'llm-search', limit: 10 }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ + query: 'llm-search', + limit: 10, + customField: 'user-value', + }) + }) + + it.concurrent('should use LLM inputMapping when user does not provide it', () => { + const userProvided = { + workflowId: 'workflow-123', + } + const llmGenerated = { + inputMapping: { query: 'llm-search', limit: 10 }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ query: 'llm-search', limit: 10 }) + }) + + it.concurrent('should use user inputMapping when LLM does not provide it', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: '{"query": "user-search"}', + } + const llmGenerated = {} + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ query: 'user-search' }) + }) + + it.concurrent('should handle invalid JSON in user inputMapping gracefully', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: 'not valid json {', + } + const llmGenerated = { + inputMapping: { query: 'llm-search' }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ query: 'llm-search' }) + }) + + it.concurrent( + 'should fill field when user typed something then removed it (field becomes empty string)', + () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: '{"query": ""}', + } + const llmGenerated = { + inputMapping: { query: 'llm-generated-search' }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.inputMapping).toEqual({ query: 'llm-generated-search' }) + } + ) + + it.concurrent('should not affect other parameters - normal override behavior', () => { + const userProvided = { + apiKey: 'user-key', + channel: '#general', + } + const llmGenerated = { + message: 'Hello world', + channel: '#random', + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + expect(merged.apiKey).toBe('user-key') + expect(merged.channel).toBe('#general') + expect(merged.message).toBe('Hello world') + }) + + it.concurrent('should preserve 0 and false as valid user values in inputMapping', () => { + const userProvided = { + workflowId: 'workflow-123', + inputMapping: '{"limit": 0, "enabled": false, "query": ""}', + } + const llmGenerated = { + inputMapping: { limit: 10, enabled: true, query: 'llm-search' }, + } + + const merged = mergeToolParameters(userProvided, llmGenerated) + + // 0 and false should be preserved (they're valid values) + // empty string should be filled by LLM + expect(merged.inputMapping).toEqual({ + limit: 0, + enabled: false, + query: 'llm-search', + }) + }) + }) + }) + describe('Type Interface Validation', () => { it.concurrent('should have properly typed ToolSchema', async () => { const schema: ToolSchema = await createLLMToolSchema(mockToolConfig, {}) @@ -304,7 +551,6 @@ describe('Tool Parameters Utils', () => { expect(typeof schema.properties).toBe('object') expect(Array.isArray(schema.required)).toBe(true) - // Verify properties have correct structure Object.values(schema.properties).forEach((prop) => { expect(prop).toHaveProperty('type') expect(prop).toHaveProperty('description') diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 17f9392831..28f561ae08 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -395,31 +395,39 @@ export async function createLLMToolSchema( // Only include parameters that the LLM should/can provide for (const [paramId, param] of Object.entries(toolConfig.params)) { - const isUserProvided = - userProvidedParams[paramId] !== undefined && - userProvidedParams[paramId] !== null && - userProvidedParams[paramId] !== '' - - // Skip parameters that user has already provided - if (isUserProvided) { - continue - } + // Special handling for workflow_executor's inputMapping parameter + // Always include in LLM schema so LLM can provide dynamic input values + // even if user has configured empty/partial inputMapping in the UI + const isWorkflowInputMapping = + toolConfig.id === 'workflow_executor' && paramId === 'inputMapping' + + if (!isWorkflowInputMapping) { + const isUserProvided = + userProvidedParams[paramId] !== undefined && + userProvidedParams[paramId] !== null && + userProvidedParams[paramId] !== '' + + // Skip parameters that user has already provided + if (isUserProvided) { + continue + } - // Skip parameters that are user-only (never shown to LLM) - if (param.visibility === 'user-only') { - continue - } + // Skip parameters that are user-only (never shown to LLM) + if (param.visibility === 'user-only') { + continue + } - // Skip hidden parameters - if (param.visibility === 'hidden') { - continue + // Skip hidden parameters + if (param.visibility === 'hidden') { + continue + } } // Add parameter to LLM schema const propertySchema = buildParameterSchema(toolConfig.id, paramId, param) - // Special handling for workflow_executor's inputMapping parameter - if (toolConfig.id === 'workflow_executor' && paramId === 'inputMapping') { + // Apply dynamic schema enrichment for workflow_executor's inputMapping + if (isWorkflowInputMapping) { const workflowId = userProvidedParams.workflowId as string if (workflowId) { await applyDynamicSchemaForWorkflow(propertySchema, workflowId) @@ -571,10 +579,60 @@ export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema { return schema } +/** + * Deep merges inputMapping objects, where LLM values fill in empty/missing user values. + * User-provided non-empty values take precedence. + */ +export function deepMergeInputMapping( + llmInputMapping: Record | undefined, + userInputMapping: Record | string | undefined +): Record { + // Parse user inputMapping if it's a JSON string + let parsedUserMapping: Record = {} + if (typeof userInputMapping === 'string') { + try { + const parsed = JSON.parse(userInputMapping) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + parsedUserMapping = parsed + } + } catch { + // Invalid JSON, treat as empty + } + } else if ( + typeof userInputMapping === 'object' && + userInputMapping !== null && + !Array.isArray(userInputMapping) + ) { + parsedUserMapping = userInputMapping + } + + // If no LLM mapping, return user mapping (or empty) + if (!llmInputMapping || typeof llmInputMapping !== 'object') { + return parsedUserMapping + } + + // Deep merge: LLM values as base, user non-empty values override + // If user provides empty object {}, LLM values fill all fields (intentional) + const merged: Record = { ...llmInputMapping } + + for (const [key, userValue] of Object.entries(parsedUserMapping)) { + // Only override LLM value if user provided a non-empty value + // Note: Using strict inequality (!==) so 0 and false are correctly preserved + if (userValue !== undefined && userValue !== null && userValue !== '') { + merged[key] = userValue + } + } + + return merged +} + /** * Merges user-provided parameters with LLM-generated parameters. * User-provided parameters take precedence, but empty strings are skipped * so that LLM-generated values are used when user clears a field. + * + * Special handling for inputMapping: deep merges so LLM can fill in + * fields that user left empty in the UI. */ export function mergeToolParameters( userProvidedParams: Record, @@ -589,11 +647,31 @@ export function mergeToolParameters( } } - // User-provided parameters take precedence (after filtering empty values) - return { - ...llmGeneratedParams, - ...filteredUserParams, + // Start with LLM params as base + const result: Record = { ...llmGeneratedParams } + + // Apply user params, with special handling for inputMapping + for (const [key, userValue] of Object.entries(filteredUserParams)) { + if (key === 'inputMapping') { + // Deep merge inputMapping so LLM values fill in empty user fields + const llmInputMapping = llmGeneratedParams.inputMapping as Record | undefined + const mergedInputMapping = deepMergeInputMapping( + llmInputMapping, + userValue as Record | string | undefined + ) + result.inputMapping = mergedInputMapping + } else { + // Normal override for other params + result[key] = userValue + } + } + + // If LLM provided inputMapping but user didn't, ensure it's included + if (llmGeneratedParams.inputMapping && !filteredUserParams.inputMapping) { + result.inputMapping = llmGeneratedParams.inputMapping } + + return result } /**