Skip to content

Commit 162abf7

Browse files
committed
perf(tools): read tool metadata instead of the registry on client paths
Cuts the last four edges that pulled `@/tools/registry` into the workspace shell. Every workspace route drops ~4,700 modules: route before after /w (canvas) 6,592 1,908 -71% /logs 6,227 1,543 -75% /tables 5,903 1,217 -79% /files 5,996 1,310 -78% workspace layout 5,751 1,063 -82% Dev cold compile of the canvas, n=3, cache cleared between runs: before 32.3s / 31.4s / 30.1s RSS 9.0-12.5 GB after 22.4s / 22.2s / 21.6s RSS 7.8-9.2 GB That lands where the `dev:minimal` escape hatch measured (20.0s / 6.7 GB) without its downside — `dev:minimal` swaps in curated registries that drop ~250 services, whereas this keeps every tool working. Rewired: - `block-outputs` -> `getToolOutputsMetadata` (needed `outputs`) - `serializer` -> `getToolParams` (needed `params`) - `validation` -> `hasToolId` (needed existence only) - `tools/params` -> `getToolMetadata` (needed `params`, `oauth`, `name`) `tools/params.ts` was the stubborn one: `mcp-dynamic-args.tsx` imports only `formatParameterLabel` from it, so the whole registry rode in behind a string helper — the same shape as the `mergeToolParameters` edge cut earlier. Adds a third generated artifact, `tool-ids.ts` (~110 KB). Resolution needs only the key set, so `@/tools/metadata` and `@/tools/metadata-outputs` both resolve through it and stay independent of each other, and an existence check costs ~110 KB instead of ~4 MB. Behaviour preservation was the risk here: `getTool` resolves an unversioned name onto its newest version, and a plain key lookup would have silently reported 246 versioned tools as missing. `resolveToolId` is reproduced against the id set and differentially tested — 4,404 probes (every id, every stripped base name, and an unknown) comparing old vs new resolution and existence: 0 mismatches. `ToolWithParameters.toolConfig` and `SubBlocksForToolInput.toolConfig` narrow from `ToolConfig` to `ToolMetadata`. The only external reader is `tool-input.tsx`, which uses `.name`.
1 parent 6bb7a7b commit 162abf7

11 files changed

Lines changed: 172 additions & 43 deletions

File tree

apps/sim/lib/workflows/blocks/block-outputs.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
type OutputCondition,
2323
type OutputFieldDefinition,
2424
} from '@/blocks/types'
25-
import { getTool } from '@/tools/utils'
25+
import { getToolOutputsMetadata } from '@/tools/metadata-outputs'
2626
import { getTrigger, isTriggerValid } from '@/triggers'
2727

2828
const logger = createLogger('BlockOutputs')
@@ -681,13 +681,13 @@ export function getToolOutputs(
681681
const toolId = blockConfig.tools.config.tool(params)
682682
if (!toolId) return {}
683683

684-
const toolConfig = getTool(toolId)
685-
if (!toolConfig?.outputs) return {}
684+
const toolOutputs = getToolOutputsMetadata(toolId)
685+
if (!toolOutputs) return {}
686686
if (includeHidden) {
687-
return toolConfig.outputs
687+
return toolOutputs
688688
}
689689
return Object.fromEntries(
690-
Object.entries(toolConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def))
690+
Object.entries(toolOutputs).filter(([_, def]) => !isHiddenFromDisplay(def))
691691
)
692692
} catch (error) {
693693
logger.warn('Failed to get tool outputs', { error })

apps/sim/lib/workflows/sanitization/validation.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { isRecordLike } from '@sim/utils/object'
44
import { getBlock } from '@/blocks/registry'
55
import { isCustomTool, isMcpTool } from '@/executor/constants'
66
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
7-
import { getTool } from '@/tools/utils'
7+
import { hasToolId } from '@/tools/tool-ids'
88

99
const logger = createLogger('WorkflowValidation')
1010

@@ -305,8 +305,7 @@ export function validateToolReference(
305305

306306
if (!isCustomTool(toolId) && !isMcpTool(toolId)) {
307307
// For built-in tools, verify they exist
308-
const tool = getTool(toolId)
309-
if (!tool) {
308+
if (!hasToolId(toolId)) {
310309
return `Block ${blockName || 'unknown'} (${blockType}): references non-existent tool '${toolId}'`
311310
}
312311
}

apps/sim/serializer/index.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { SubBlockConfig } from '@/blocks/types'
2121
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
2222
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
2323
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
24-
import { getTool } from '@/tools/utils'
24+
import { getToolParams } from '@/tools/metadata'
2525

2626
const logger = createLogger('Serializer')
2727

@@ -637,13 +637,13 @@ export function collectBlockFieldIssues(
637637
// Get the tool configuration to check parameter visibility
638638
const toolAccess = blockConfig.tools?.access
639639
const currentToolId = toolAccess?.length > 0 ? selectToolId(blockConfig, params) : null
640-
const currentTool = currentToolId ? getTool(currentToolId) : null
640+
const currentToolParams = currentToolId ? getToolParams(currentToolId) : undefined
641641

642642
// Validate tool parameters (for blocks with tools).
643643
// Lookup contract: a tool param's value lives under its own paramId in `params`.
644644
// Block subBlocks align via either `id === paramId` or `canonicalParamId === paramId`.
645-
if (currentTool) {
646-
Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]: [string, any]) => {
645+
if (currentToolParams) {
646+
Object.entries(currentToolParams).forEach(([paramId, paramConfig]: [string, any]) => {
647647
if (paramConfig.required && paramConfig.visibility === 'user-only') {
648648
const matchingConfigs =
649649
blockConfig.subBlocks?.filter(
@@ -699,7 +699,7 @@ export function collectBlockFieldIssues(
699699
}
700700

701701
// Validate required subBlocks not covered by tool params (e.g., blocks with empty tools.access)
702-
const validatedByTool = new Set(currentTool ? Object.keys(currentTool.params || {}) : [])
702+
const validatedByTool = new Set(currentToolParams ? Object.keys(currentToolParams) : [])
703703

704704
blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => {
705705
if (validatedByTool.has(subBlockConfig.id)) {

apps/sim/tools/generated/tool-ids.ts

Lines changed: 9 additions & 0 deletions
Large diffs are not rendered by default.

apps/sim/tools/metadata-outputs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import rawOutputs from '@/tools/generated/tool-outputs'
2+
import { resolveToolId } from '@/tools/tool-ids'
23
import type { ToolConfig } from '@/tools/types'
34

45
/**
@@ -28,5 +29,6 @@ const outputs: Record<string, ToolOutputs> = rawOutputs as Record<string, ToolOu
2829
* `Object.hasOwn` rather than a bare lookup — see `getToolMetadata` for why.
2930
*/
3031
export function getToolOutputsMetadata(toolId: string): ToolOutputs | undefined {
31-
return Object.hasOwn(outputs, toolId) ? outputs[toolId] : undefined
32+
const resolved = resolveToolId(toolId)
33+
return Object.hasOwn(outputs, resolved) ? outputs[resolved] : undefined
3234
}

apps/sim/tools/metadata.test.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { getToolIds, getToolMetadata, getToolParams, hasToolMetadata } from '@/tools/metadata'
5+
import { getToolMetadata, getToolParams } from '@/tools/metadata'
66
import { getToolOutputsMetadata } from '@/tools/metadata-outputs'
7+
import { getToolIds, hasToolId, resolveToolId } from '@/tools/tool-ids'
78

89
/**
910
* Guards the properties the generated artifacts are relied on for. The
@@ -22,7 +23,7 @@ describe('generated tool metadata', () => {
2223
})
2324

2425
it('reports unknown tools as absent without throwing', () => {
25-
expect(hasToolMetadata('definitely_not_a_tool')).toBe(false)
26+
expect(hasToolId('definitely_not_a_tool')).toBe(false)
2627
expect(getToolMetadata('definitely_not_a_tool')).toBeUndefined()
2728
expect(getToolParams('definitely_not_a_tool')).toBeUndefined()
2829
expect(getToolOutputsMetadata('definitely_not_a_tool')).toBeUndefined()
@@ -43,10 +44,44 @@ describe('generated tool metadata', () => {
4344
expect(getToolMetadata(key)).toBeUndefined()
4445
expect(getToolParams(key)).toBeUndefined()
4546
expect(getToolOutputsMetadata(key)).toBeUndefined()
46-
expect(hasToolMetadata(key)).toBe(false)
47+
expect(hasToolId(key)).toBe(false)
4748
}
4849
)
4950

51+
/**
52+
* `getTool` resolves an unversioned name onto the newest version, and callers
53+
* migrated off it depend on that. A plain key lookup would silently report
54+
* versioned tools as missing.
55+
*/
56+
describe('version resolution', () => {
57+
/**
58+
* Only a versioned id whose base name is *not* itself registered exercises
59+
* resolution — where both exist, the base name resolves to itself.
60+
*/
61+
const versionedId = getToolIds().find(
62+
(id) => /_v[2-9]\d*$/.test(id) && !getToolIds().includes(id.replace(/_v\d+$/, ''))
63+
)
64+
65+
it('has at least one versioned tool to exercise', () => {
66+
expect(versionedId).toBeDefined()
67+
})
68+
69+
it('maps an unversioned name onto the newest version', () => {
70+
const baseName = (versionedId as string).replace(/_v\d+$/, '')
71+
expect(getToolIds()).not.toContain(baseName)
72+
expect(resolveToolId(baseName)).toBe(versionedId)
73+
expect(hasToolId(baseName)).toBe(true)
74+
expect(getToolMetadata(baseName)?.id).toBe(getToolMetadata(versionedId as string)?.id)
75+
expect(getToolOutputsMetadata(baseName)).toEqual(
76+
getToolOutputsMetadata(versionedId as string)
77+
)
78+
})
79+
80+
it('returns an unknown name unchanged', () => {
81+
expect(resolveToolId('definitely_not_a_tool')).toBe('definitely_not_a_tool')
82+
})
83+
})
84+
5085
/**
5186
* The registry contains a null param entry (`stt_deepgram_v2`), which crashes
5287
* any consumer that iterates params unguarded. The generator strips those, so

apps/sim/tools/metadata.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import rawMetadata from '@/tools/generated/tool-metadata'
2+
import { resolveToolId } from '@/tools/tool-ids'
23
import type { OAuthConfig, ToolConfig } from '@/tools/types'
34

45
/**
@@ -15,7 +16,10 @@ import type { OAuthConfig, ToolConfig } from '@/tools/types'
1516
*
1617
* Outputs live in `@/tools/metadata-outputs`, not here: they are the larger half
1718
* of the data and have a single consumer, so keeping them in a separate module
18-
* means callers that only need params don't pay for them.
19+
* means callers that only need params don't pay for them. Callers that need
20+
* neither should use `@/tools/tool-ids`, which is ~40x smaller again.
21+
*
22+
* Lookups resolve unversioned names the same way `getTool` does.
1923
*/
2024
export interface ToolMetadata {
2125
id: string
@@ -33,16 +37,6 @@ export interface ToolMetadata {
3337
*/
3438
const metadata: Record<string, ToolMetadata> = rawMetadata as Record<string, ToolMetadata>
3539

36-
/** Every registered tool id, including versioned variants. */
37-
export function getToolIds(): string[] {
38-
return Object.keys(metadata)
39-
}
40-
41-
/** Whether `toolId` names a built-in tool. Cheaper than resolving its metadata. */
42-
export function hasToolMetadata(toolId: string): boolean {
43-
return Object.hasOwn(metadata, toolId)
44-
}
45-
4640
/**
4741
* Serializable metadata for a built-in tool, or `undefined` if unknown.
4842
*
@@ -52,7 +46,8 @@ export function hasToolMetadata(toolId: string): boolean {
5246
* metadata.
5347
*/
5448
export function getToolMetadata(toolId: string): ToolMetadata | undefined {
55-
return Object.hasOwn(metadata, toolId) ? metadata[toolId] : undefined
49+
const resolved = resolveToolId(toolId)
50+
return Object.hasOwn(metadata, resolved) ? metadata[resolved] : undefined
5651
}
5752

5853
/** Declared parameters for a built-in tool, or `undefined` if unknown. */

apps/sim/tools/params.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, describe, expect, it, vi } from 'vitest'
22
import { mergeToolParameters } from '@/tools/merge-params'
3+
import * as toolMetadata from '@/tools/metadata'
34
import {
45
createExecutionToolSchema,
56
createLLMToolSchema,
@@ -15,7 +16,6 @@ import {
1516
validateToolParameters,
1617
} from '@/tools/params'
1718
import type { HttpMethod, ParameterVisibility } from '@/tools/types'
18-
import * as toolsUtils from '@/tools/utils'
1919

2020
const mockToolConfig = {
2121
id: 'test_tool',
@@ -58,11 +58,13 @@ const mockToolConfig = {
5858

5959
/**
6060
* Spy on the real module namespace instead of vi.mock: under `isolate: false`
61-
* `@/tools/params` may already be cached bound to the real `@/tools/utils`
61+
* `@/tools/params` may already be cached bound to the real `@/tools/metadata`
6262
* module, so patching the shared namespace is the only wiring that always
6363
* applies.
6464
*/
65-
const getToolSpy = vi.spyOn(toolsUtils, 'getTool').mockImplementation(((toolId: string) => {
65+
const getToolSpy = vi.spyOn(toolMetadata, 'getToolMetadata').mockImplementation(((
66+
toolId: string
67+
) => {
6668
if (toolId === 'test_tool') {
6769
return mockToolConfig
6870
}
@@ -76,7 +78,7 @@ const getToolSpy = vi.spyOn(toolsUtils, 'getTool').mockImplementation(((toolId:
7678
}
7779
}
7880
return null
79-
}) as unknown as typeof toolsUtils.getTool)
81+
}) as unknown as typeof toolMetadata.getToolMetadata)
8082

8183
afterAll(() => {
8284
getToolSpy.mockRestore()

apps/sim/tools/params.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@ import type {
1818
GenerationType,
1919
} from '@/blocks/types'
2020
import { isNonEmpty } from '@/tools/merge-params'
21+
import { getToolMetadata, type ToolMetadata } from '@/tools/metadata'
2122
import { safeAssign } from '@/tools/safe-assign'
2223
import type {
2324
OAuthConfig,
2425
ParameterVisibility,
2526
ToolConfig,
2627
ToolParameterItemSchema,
2728
} from '@/tools/types'
28-
import { getTool } from '@/tools/utils'
2929

3030
const logger = createLogger('ToolsParams')
3131
type ToolParamDefinition = ToolConfig['params'][string]
@@ -173,7 +173,7 @@ export interface ToolParameterConfig {
173173
}
174174

175175
export interface ToolWithParameters {
176-
toolConfig: ToolConfig
176+
toolConfig: ToolMetadata
177177
allParameters: ToolParameterConfig[]
178178
userInputParameters: ToolParameterConfig[] // Parameters shown to user
179179
requiredParameters: ToolParameterConfig[] // Must be filled by user or LLM
@@ -301,7 +301,7 @@ export function getToolParametersConfig(
301301
blockConfigOverride?: Pick<ToolInputBlockConfig, 'subBlocks'>
302302
): ToolWithParameters | null {
303303
try {
304-
const toolConfig = getTool(toolId)
304+
const toolConfig = getToolMetadata(toolId)
305305
if (!toolConfig) {
306306
logger.warn(`Tool not found: ${toolId}`)
307307
return null
@@ -971,7 +971,7 @@ const EXCLUDED_SUBBLOCK_TYPES = new Set([
971971
])
972972

973973
export interface SubBlocksForToolInput {
974-
toolConfig: ToolConfig
974+
toolConfig: ToolMetadata
975975
subBlocks: BlockSubBlockConfig[]
976976
oauthConfig?: OAuthConfig
977977
}
@@ -992,7 +992,7 @@ export function getSubBlocksForToolInput(
992992
blockConfigOverride?: Pick<ToolInputBlockConfig, 'subBlocks'>
993993
): SubBlocksForToolInput | null {
994994
try {
995-
const toolConfig = getTool(toolId)
995+
const toolConfig = getToolMetadata(toolId)
996996
if (!toolConfig) {
997997
logger.warn(`Tool not found: ${toolId}`)
998998
return null

apps/sim/tools/tool-ids.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { stripVersionSuffix } from '@sim/utils/string'
2+
import rawToolIds from '@/tools/generated/tool-ids'
3+
4+
/**
5+
* Tool id resolution, without importing the executable registry.
6+
*
7+
* Resolving a tool name and checking whether one exists need only the registry's
8+
* key set — never a `ToolConfig` — so this module carries just the ids (~100 KB
9+
* versus ~4 MB for params or outputs). `@/tools/metadata` and
10+
* `@/tools/metadata-outputs` both resolve through here, which is what keeps them
11+
* independent of each other.
12+
*
13+
* Semantics mirror `resolveToolId`/`getTool` in `@/tools/utils` exactly,
14+
* including returning the input unchanged when nothing matches. See
15+
* `.agents/skills/tool-registry-boundary/SKILL.md`.
16+
*/
17+
const toolIds: string[] = rawToolIds
18+
19+
const toolIdSet = new Set(toolIds)
20+
21+
/**
22+
* Base name -> newest versioned id, built once.
23+
*
24+
* `@/tools/utils` rebuilds this on every unresolved lookup; the id set is static
25+
* at runtime, so caching it is behaviour-identical and drops the repeated O(n)
26+
* scan.
27+
*/
28+
let latestByBaseName: Map<string, string> | null = null
29+
30+
function getLatestByBaseName(): Map<string, string> {
31+
if (latestByBaseName) return latestByBaseName
32+
33+
const versions = new Map<string, { toolId: string; version: number }>()
34+
for (const toolId of toolIds) {
35+
const baseName = stripVersionSuffix(toolId)
36+
const versionMatch = toolId.match(/_v(\d+)$/)
37+
const version = versionMatch ? Number.parseInt(versionMatch[1], 10) : 1
38+
const previous = versions.get(baseName)
39+
if (!previous || version > previous.version) {
40+
versions.set(baseName, { toolId, version })
41+
}
42+
}
43+
44+
latestByBaseName = new Map()
45+
for (const [baseName, { toolId }] of versions) {
46+
latestByBaseName.set(baseName, toolId)
47+
}
48+
return latestByBaseName
49+
}
50+
51+
/** Every registered tool id, including versioned variants. */
52+
export function getToolIds(): string[] {
53+
return toolIds
54+
}
55+
56+
/**
57+
* Resolves a tool name to its registered id, mapping an unversioned name onto
58+
* the newest version (`notion_search` -> `notion_search_v2`). Returns the input
59+
* unchanged when nothing matches, matching `@/tools/utils`.
60+
*/
61+
export function resolveToolId(toolName: string): string {
62+
if (toolIdSet.has(toolName)) return toolName
63+
return getLatestByBaseName().get(toolName) ?? toolName
64+
}
65+
66+
/** Whether `toolId` names a built-in tool, resolving unversioned names. */
67+
export function hasToolId(toolId: string): boolean {
68+
return toolIdSet.has(resolveToolId(toolId))
69+
}

0 commit comments

Comments
 (0)