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.
This commit is contained in:
Waleed
2026-08-01 11:27:36 -07:00
committed by GitHub
parent 28abbc94c6
commit 9299cee58d
5 changed files with 127 additions and 110 deletions
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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')
+123
View File
@@ -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
}
+1 -1
View File
@@ -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,
+1 -107
View File
@@ -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<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
*/