Skip to content

Commit 9299cee

Browse files
authored
perf(tools): move mergeToolParameters into a registry-free leaf module (#6152)
* perf(tools): move mergeToolParameters into a registry-free leaf module `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. * refactor(tools): make deepMergeInputMapping module-private 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.
1 parent 28abbc9 commit 9299cee

5 files changed

Lines changed: 127 additions & 110 deletions

File tree

apps/sim/executor/handlers/pi/sim-tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend'
1616
import type { ExecutionContext } from '@/executor/types'
1717
import { transformBlockTool } from '@/providers/utils'
1818
import { executeTool } from '@/tools'
19-
import { mergeToolParameters } from '@/tools/params'
19+
import { mergeToolParameters } from '@/tools/merge-params'
2020
import type { ToolResponse } from '@/tools/types'
2121
import { getTool } from '@/tools/utils'
2222
import { getToolAsync } from '@/tools/utils.server'

apps/sim/providers/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import {
5151
} from '@/providers/models'
5252
import type { ProviderId, ProviderToolConfig } from '@/providers/types'
5353
import { useProvidersStore } from '@/stores/providers/store'
54-
import { mergeToolParameters } from '@/tools/params'
54+
import { mergeToolParameters } from '@/tools/merge-params'
5555

5656
const logger = createLogger('ProviderUtils')
5757

apps/sim/tools/merge-params.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { isRecordLike } from '@sim/utils/object'
2+
import { isEmptyTagValue } from '@/tools/shared/tags'
3+
4+
/**
5+
* Merging of user-provided and LLM-generated tool parameters.
6+
*
7+
* Deliberately kept in its own leaf module rather than in `@/tools/params`.
8+
* `params.ts` imports `getTool` from `@/tools/utils`, which statically imports
9+
* the 4,300-entry `@/tools/registry` barrel — so importing anything from
10+
* `params.ts` drags the whole tool registry into the caller's module graph
11+
* (4,926 modules, versus 17 without that edge).
12+
*
13+
* `providers/utils.ts` needs only `mergeToolParameters`, and this function needs
14+
* no tool lookup at all, so it lives here and that import edge stays cheap.
15+
* Nothing in this file may import `@/tools/utils`, `@/tools/registry`, or
16+
* `@/tools/params`.
17+
*/
18+
19+
/** Checks if a value is non-empty (not undefined, null, or empty string). */
20+
export function isNonEmpty(value: unknown): boolean {
21+
return value !== undefined && value !== null && value !== ''
22+
}
23+
24+
/**
25+
* Deep merges inputMapping objects, where LLM values fill in empty/missing user values.
26+
* User-provided non-empty values take precedence.
27+
*
28+
* Module-private: only {@link mergeToolParameters} needs it. It was exported from
29+
* `@/tools/params` but never imported anywhere.
30+
*/
31+
function deepMergeInputMapping(
32+
llmInputMapping: Record<string, unknown> | undefined,
33+
userInputMapping: Record<string, unknown> | string | undefined
34+
): Record<string, unknown> {
35+
// Parse user inputMapping if it's a JSON string
36+
let parsedUserMapping: Record<string, unknown> = {}
37+
if (typeof userInputMapping === 'string') {
38+
try {
39+
const parsed = JSON.parse(userInputMapping)
40+
if (isRecordLike(parsed)) {
41+
parsedUserMapping = parsed
42+
}
43+
} catch {
44+
// Invalid JSON, treat as empty
45+
}
46+
} else if (
47+
typeof userInputMapping === 'object' &&
48+
userInputMapping !== null &&
49+
!Array.isArray(userInputMapping)
50+
) {
51+
parsedUserMapping = userInputMapping
52+
}
53+
54+
// If no LLM mapping, return user mapping (or empty)
55+
if (!llmInputMapping || typeof llmInputMapping !== 'object') {
56+
return parsedUserMapping
57+
}
58+
59+
// Deep merge: LLM values as base, user non-empty values override
60+
// If user provides empty object {}, LLM values fill all fields (intentional)
61+
const merged: Record<string, unknown> = { ...llmInputMapping }
62+
63+
for (const [key, userValue] of Object.entries(parsedUserMapping)) {
64+
// Only override LLM value if user provided a non-empty value
65+
if (isNonEmpty(userValue)) {
66+
merged[key] = userValue
67+
}
68+
}
69+
70+
return merged
71+
}
72+
73+
/**
74+
* Merges user-provided parameters with LLM-generated parameters.
75+
* User-provided parameters take precedence, but empty strings are skipped
76+
* so that LLM-generated values are used when user clears a field.
77+
*
78+
* Special handling for inputMapping: deep merges so LLM can fill in
79+
* fields that user left empty in the UI.
80+
*/
81+
export function mergeToolParameters(
82+
userProvidedParams: Record<string, unknown>,
83+
llmGeneratedParams: Record<string, unknown>
84+
): Record<string, unknown> {
85+
// Filter out empty and effectively-empty values from user-provided params
86+
// so that cleared fields don't override LLM values
87+
const filteredUserParams: Record<string, unknown> = {}
88+
for (const [key, value] of Object.entries(userProvidedParams)) {
89+
if (isNonEmpty(value)) {
90+
// Skip tag-based params if they're effectively empty (only default/unfilled entries)
91+
if ((key === 'documentTags' || key === 'tagFilters') && isEmptyTagValue(value)) {
92+
continue
93+
}
94+
filteredUserParams[key] = value
95+
}
96+
}
97+
98+
// Start with LLM params as base
99+
const result: Record<string, unknown> = { ...llmGeneratedParams }
100+
101+
// Apply user params, with special handling for inputMapping
102+
for (const [key, userValue] of Object.entries(filteredUserParams)) {
103+
if (key === 'inputMapping') {
104+
// Deep merge inputMapping so LLM values fill in empty user fields
105+
const llmInputMapping = llmGeneratedParams.inputMapping as Record<string, unknown> | undefined
106+
const mergedInputMapping = deepMergeInputMapping(
107+
llmInputMapping,
108+
userValue as Record<string, unknown> | string | undefined
109+
)
110+
result.inputMapping = mergedInputMapping
111+
} else {
112+
// Normal override for other params
113+
result[key] = userValue
114+
}
115+
}
116+
117+
// If LLM provided inputMapping but user didn't, ensure it's included
118+
if (llmGeneratedParams.inputMapping && !filteredUserParams.inputMapping) {
119+
result.inputMapping = llmGeneratedParams.inputMapping
120+
}
121+
122+
return result
123+
}

apps/sim/tools/params.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterAll, describe, expect, it, vi } from 'vitest'
2+
import { mergeToolParameters } from '@/tools/merge-params'
23
import {
34
createExecutionToolSchema,
45
createLLMToolSchema,
@@ -8,7 +9,6 @@ import {
89
getSubBlocksForToolInput,
910
getToolParametersConfig,
1011
isPasswordParameter,
11-
mergeToolParameters,
1212
type ToolParameterConfig,
1313
type ToolSchema,
1414
type ValidationResult,

apps/sim/tools/params.ts

Lines changed: 1 addition & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createLogger } from '@sim/logger'
2-
import { isRecordLike } from '@sim/utils/object'
32
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
43
import {
54
buildCanonicalIndex,
@@ -18,8 +17,8 @@ import type {
1817
SubBlockConfig as BlockSubBlockConfig,
1918
GenerationType,
2019
} from '@/blocks/types'
20+
import { isNonEmpty } from '@/tools/merge-params'
2121
import { safeAssign } from '@/tools/safe-assign'
22-
import { isEmptyTagValue } from '@/tools/shared/tags'
2322
import type {
2423
OAuthConfig,
2524
ParameterVisibility,
@@ -31,13 +30,6 @@ import { getTool } from '@/tools/utils'
3130
const logger = createLogger('ToolsParams')
3231
type ToolParamDefinition = ToolConfig['params'][string]
3332

34-
/**
35-
* Checks if a value is non-empty (not undefined, null, or empty string)
36-
*/
37-
export function isNonEmpty(value: unknown): boolean {
38-
return value !== undefined && value !== null && value !== ''
39-
}
40-
4133
// ============================================================================
4234
// Tag/Value Parsing Utilities
4335
// ============================================================================
@@ -827,104 +819,6 @@ export function createExecutionToolSchema(toolConfig: ToolConfig): ToolSchema {
827819
return schema
828820
}
829821

830-
/**
831-
* Deep merges inputMapping objects, where LLM values fill in empty/missing user values.
832-
* User-provided non-empty values take precedence.
833-
*/
834-
export function deepMergeInputMapping(
835-
llmInputMapping: Record<string, unknown> | undefined,
836-
userInputMapping: Record<string, unknown> | string | undefined
837-
): Record<string, unknown> {
838-
// Parse user inputMapping if it's a JSON string
839-
let parsedUserMapping: Record<string, unknown> = {}
840-
if (typeof userInputMapping === 'string') {
841-
try {
842-
const parsed = JSON.parse(userInputMapping)
843-
if (isRecordLike(parsed)) {
844-
parsedUserMapping = parsed
845-
}
846-
} catch {
847-
// Invalid JSON, treat as empty
848-
}
849-
} else if (
850-
typeof userInputMapping === 'object' &&
851-
userInputMapping !== null &&
852-
!Array.isArray(userInputMapping)
853-
) {
854-
parsedUserMapping = userInputMapping
855-
}
856-
857-
// If no LLM mapping, return user mapping (or empty)
858-
if (!llmInputMapping || typeof llmInputMapping !== 'object') {
859-
return parsedUserMapping
860-
}
861-
862-
// Deep merge: LLM values as base, user non-empty values override
863-
// If user provides empty object {}, LLM values fill all fields (intentional)
864-
const merged: Record<string, unknown> = { ...llmInputMapping }
865-
866-
for (const [key, userValue] of Object.entries(parsedUserMapping)) {
867-
// Only override LLM value if user provided a non-empty value
868-
if (isNonEmpty(userValue)) {
869-
merged[key] = userValue
870-
}
871-
}
872-
873-
return merged
874-
}
875-
876-
/**
877-
* Merges user-provided parameters with LLM-generated parameters.
878-
* User-provided parameters take precedence, but empty strings are skipped
879-
* so that LLM-generated values are used when user clears a field.
880-
*
881-
* Special handling for inputMapping: deep merges so LLM can fill in
882-
* fields that user left empty in the UI.
883-
*/
884-
export function mergeToolParameters(
885-
userProvidedParams: Record<string, unknown>,
886-
llmGeneratedParams: Record<string, unknown>
887-
): Record<string, unknown> {
888-
// Filter out empty and effectively-empty values from user-provided params
889-
// so that cleared fields don't override LLM values
890-
const filteredUserParams: Record<string, unknown> = {}
891-
for (const [key, value] of Object.entries(userProvidedParams)) {
892-
if (isNonEmpty(value)) {
893-
// Skip tag-based params if they're effectively empty (only default/unfilled entries)
894-
if ((key === 'documentTags' || key === 'tagFilters') && isEmptyTagValue(value)) {
895-
continue
896-
}
897-
filteredUserParams[key] = value
898-
}
899-
}
900-
901-
// Start with LLM params as base
902-
const result: Record<string, unknown> = { ...llmGeneratedParams }
903-
904-
// Apply user params, with special handling for inputMapping
905-
for (const [key, userValue] of Object.entries(filteredUserParams)) {
906-
if (key === 'inputMapping') {
907-
// Deep merge inputMapping so LLM values fill in empty user fields
908-
const llmInputMapping = llmGeneratedParams.inputMapping as Record<string, unknown> | undefined
909-
const mergedInputMapping = deepMergeInputMapping(
910-
llmInputMapping,
911-
userValue as Record<string, unknown> | string | undefined
912-
)
913-
result.inputMapping = mergedInputMapping
914-
} else {
915-
// Normal override for other params
916-
result[key] = userValue
917-
}
918-
}
919-
920-
// If LLM provided inputMapping but user didn't, ensure it's included
921-
if (llmGeneratedParams.inputMapping && !filteredUserParams.inputMapping) {
922-
result.inputMapping = llmGeneratedParams.inputMapping
923-
}
924-
925-
return result
926-
}
927-
928822
/**
929823
* Filters out user-provided parameters from tool schema for LLM
930824
*/

0 commit comments

Comments
 (0)