mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user