From 57a676538962725c714f50051a9fd08945fa4e10 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 31 Jul 2026 23:02:09 -0700 Subject: [PATCH 1/2] perf(tools): move mergeToolParameters into a registry-free leaf module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `providers/utils.ts` imports exactly one thing from `@/tools/params`: `mergeToolParameters`. That function performs no tool lookup at all — it merges two plain param objects. But `params.ts` imports `getTool` from `@/tools/utils`, which statically imports the 4,300-entry `@/tools/registry` barrel, so that one-symbol import was dragging the entire tool registry into every module graph that reached it. Measured with a module-graph walk from each entry: tools/params.ts 4,926 modules (registry reachable) providers/utils.ts 4,926 modules (registry reachable) -> 22 modules ✅ tools/merge-params.ts 2 modules (registry NOT reachable) `mergeToolParameters`, `deepMergeInputMapping` and `isNonEmpty` move to `@/tools/merge-params`, which is forbidden from importing `@/tools/utils`, `@/tools/registry` or `@/tools/params`. `params.ts` now imports `isNonEmpty` from there; its `isRecordLike` and `isEmptyTagValue` imports became unused and are dropped. The two consumers (`providers/utils.ts`, `executor/handlers/pi/sim-tools.ts`) import from the new module directly rather than via a re-export, per the no-re-exports rule. This is preparation, not the payoff. The canvas route still reaches the registry through three other edges (block-outputs, serializer, sanitization/validation) — all four are redundant paths and must all be cut before the route's module count moves. Those follow in the metadata-manifest PRs. Behaviour is unchanged: the moved functions are copied verbatim. --- apps/sim/executor/handlers/pi/sim-tools.ts | 2 +- apps/sim/providers/utils.ts | 2 +- apps/sim/tools/merge-params.ts | 120 +++++++++++++++++++++ apps/sim/tools/params.test.ts | 2 +- apps/sim/tools/params.ts | 108 +------------------ 5 files changed, 124 insertions(+), 110 deletions(-) create mode 100644 apps/sim/tools/merge-params.ts diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts index f8e56a2ab93..3c1af631678 100644 --- a/apps/sim/executor/handlers/pi/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/sim-tools.ts @@ -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' diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index a94f2d8f657..12b7c0cc966 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -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') diff --git a/apps/sim/tools/merge-params.ts b/apps/sim/tools/merge-params.ts new file mode 100644 index 00000000000..73dfcbacebd --- /dev/null +++ b/apps/sim/tools/merge-params.ts @@ -0,0 +1,120 @@ +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. + */ +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 (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 = { ...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, + llmGeneratedParams: Record +): Record { + // Filter out empty and effectively-empty values from user-provided params + // so that cleared fields don't override LLM values + const filteredUserParams: Record = {} + 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 = { ...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 +} diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index ec912708eec..02bd77b333e 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect, it, vi } from 'vitest' +import { mergeToolParameters } from '@/tools/merge-params' import { createExecutionToolSchema, createLLMToolSchema, @@ -8,7 +9,6 @@ import { getSubBlocksForToolInput, getToolParametersConfig, isPasswordParameter, - mergeToolParameters, type ToolParameterConfig, type ToolSchema, type ValidationResult, diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 9ea7d81b1bf..ada48406eb3 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { isRecordLike } from '@sim/utils/object' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { buildCanonicalIndex, @@ -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, @@ -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 // ============================================================================ @@ -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 | 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 (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 = { ...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, - llmGeneratedParams: Record -): Record { - // Filter out empty and effectively-empty values from user-provided params - // so that cleared fields don't override LLM values - const filteredUserParams: Record = {} - 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 = { ...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 -} - /** * Filters out user-provided parameters from tool schema for LLM */ From 41182a3d7d91e04d9546284e53656bb42d91e312 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 1 Aug 2026 10:20:27 -0700 Subject: [PATCH 2/2] refactor(tools): make deepMergeInputMapping module-private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was exported from `@/tools/params` and imported by nothing — a private helper of `mergeToolParameters` that had leaked into the public surface. Since this move created the module, the export goes with it rather than being carried forward. Verified zero consumers repo-wide before dropping it. --- apps/sim/tools/merge-params.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/tools/merge-params.ts b/apps/sim/tools/merge-params.ts index 73dfcbacebd..1693f7c6761 100644 --- a/apps/sim/tools/merge-params.ts +++ b/apps/sim/tools/merge-params.ts @@ -24,8 +24,11 @@ export function isNonEmpty(value: unknown): boolean { /** * 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. */ -export function deepMergeInputMapping( +function deepMergeInputMapping( llmInputMapping: Record | undefined, userInputMapping: Record | string | undefined ): Record {