Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/executor/handlers/pi/sim-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
import type { ExecutionContext } from '@/executor/types'
import { transformBlockTool } from '@/providers/utils'
import { executeTool } from '@/tools'
import { mergeToolParameters } from '@/tools/params'
import { mergeToolParameters } from '@/tools/merge-params'
import type { ToolResponse } from '@/tools/types'
import { getTool } from '@/tools/utils'
import { getToolAsync } from '@/tools/utils.server'
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/providers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
} from '@/providers/models'
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
import { useProvidersStore } from '@/stores/providers/store'
import { mergeToolParameters } from '@/tools/params'
import { mergeToolParameters } from '@/tools/merge-params'

const logger = createLogger('ProviderUtils')

Expand Down
123 changes: 123 additions & 0 deletions apps/sim/tools/merge-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { isRecordLike } from '@sim/utils/object'
import { isEmptyTagValue } from '@/tools/shared/tags'

/**
* Merging of user-provided and LLM-generated tool parameters.
*
* Deliberately kept in its own leaf module rather than in `@/tools/params`.
* `params.ts` imports `getTool` from `@/tools/utils`, which statically imports
* the 4,300-entry `@/tools/registry` barrel — so importing anything from
* `params.ts` drags the whole tool registry into the caller's module graph
* (4,926 modules, versus 17 without that edge).
*
* `providers/utils.ts` needs only `mergeToolParameters`, and this function needs
* no tool lookup at all, so it lives here and that import edge stays cheap.
* Nothing in this file may import `@/tools/utils`, `@/tools/registry`, or
* `@/tools/params`.
*/

/** Checks if a value is non-empty (not undefined, null, or empty string). */
export function isNonEmpty(value: unknown): boolean {
return value !== undefined && value !== null && value !== ''
}

/**
* Deep merges inputMapping objects, where LLM values fill in empty/missing user values.
* User-provided non-empty values take precedence.
*
* Module-private: only {@link mergeToolParameters} needs it. It was exported from
* `@/tools/params` but never imported anywhere.
*/
function deepMergeInputMapping(
llmInputMapping: Record<string, unknown> | undefined,
userInputMapping: Record<string, unknown> | string | undefined
): Record<string, unknown> {
// Parse user inputMapping if it's a JSON string
let parsedUserMapping: Record<string, unknown> = {}
if (typeof userInputMapping === 'string') {
try {
const parsed = JSON.parse(userInputMapping)
if (isRecordLike(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<string, unknown> = { ...llmInputMapping }

for (const [key, userValue] of Object.entries(parsedUserMapping)) {
// Only override LLM value if user provided a non-empty value
if (isNonEmpty(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<string, unknown>,
llmGeneratedParams: Record<string, unknown>
): Record<string, unknown> {
// Filter out empty and effectively-empty values from user-provided params
// so that cleared fields don't override LLM values
const filteredUserParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(userProvidedParams)) {
if (isNonEmpty(value)) {
// Skip tag-based params if they're effectively empty (only default/unfilled entries)
if ((key === 'documentTags' || key === 'tagFilters') && isEmptyTagValue(value)) {
continue
}
filteredUserParams[key] = value
}
}

// Start with LLM params as base
const result: Record<string, unknown> = { ...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<string, unknown> | undefined
const mergedInputMapping = deepMergeInputMapping(
llmInputMapping,
userValue as Record<string, unknown> | 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
}
2 changes: 1 addition & 1 deletion apps/sim/tools/params.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterAll, describe, expect, it, vi } from 'vitest'
import { mergeToolParameters } from '@/tools/merge-params'
import {
createExecutionToolSchema,
createLLMToolSchema,
Expand All @@ -8,7 +9,6 @@ import {
getSubBlocksForToolInput,
getToolParametersConfig,
isPasswordParameter,
mergeToolParameters,
type ToolParameterConfig,
type ToolSchema,
type ValidationResult,
Expand Down
108 changes: 1 addition & 107 deletions apps/sim/tools/params.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import { isRecordLike } from '@sim/utils/object'
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
import {
buildCanonicalIndex,
Expand All @@ -18,8 +17,8 @@ import type {
SubBlockConfig as BlockSubBlockConfig,
GenerationType,
} from '@/blocks/types'
import { isNonEmpty } from '@/tools/merge-params'
import { safeAssign } from '@/tools/safe-assign'
import { isEmptyTagValue } from '@/tools/shared/tags'
import type {
OAuthConfig,
ParameterVisibility,
Expand All @@ -31,13 +30,6 @@ import { getTool } from '@/tools/utils'
const logger = createLogger('ToolsParams')
type ToolParamDefinition = ToolConfig['params'][string]

/**
* Checks if a value is non-empty (not undefined, null, or empty string)
*/
export function isNonEmpty(value: unknown): boolean {
return value !== undefined && value !== null && value !== ''
}

// ============================================================================
// Tag/Value Parsing Utilities
// ============================================================================
Expand Down Expand Up @@ -827,104 +819,6 @@ 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<string, unknown> | undefined,
userInputMapping: Record<string, unknown> | string | undefined
): Record<string, unknown> {
// Parse user inputMapping if it's a JSON string
let parsedUserMapping: Record<string, unknown> = {}
if (typeof userInputMapping === 'string') {
try {
const parsed = JSON.parse(userInputMapping)
if (isRecordLike(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<string, unknown> = { ...llmInputMapping }

for (const [key, userValue] of Object.entries(parsedUserMapping)) {
// Only override LLM value if user provided a non-empty value
if (isNonEmpty(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<string, unknown>,
llmGeneratedParams: Record<string, unknown>
): Record<string, unknown> {
// Filter out empty and effectively-empty values from user-provided params
// so that cleared fields don't override LLM values
const filteredUserParams: Record<string, unknown> = {}
for (const [key, value] of Object.entries(userProvidedParams)) {
if (isNonEmpty(value)) {
// Skip tag-based params if they're effectively empty (only default/unfilled entries)
if ((key === 'documentTags' || key === 'tagFilters') && isEmptyTagValue(value)) {
continue
}
filteredUserParams[key] = value
}
}

// Start with LLM params as base
const result: Record<string, unknown> = { ...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<string, unknown> | undefined
const mergedInputMapping = deepMergeInputMapping(
llmInputMapping,
userValue as Record<string, unknown> | 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
}

/**
* Filters out user-provided parameters from tool schema for LLM
*/
Expand Down
Loading