You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perf(tools): read tool metadata instead of the registry on client paths (#6155)
* 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`.
* docs(tools): point the boundary skill at the three metadata modules
The skill still routed `hasToolMetadata` and `getToolIds` to `@/tools/metadata`,
but this PR moved id resolution into `@/tools/tool-ids`. Left as-is it would
send the next caller to the 4 MB module for an existence check that costs
110 KB — the exact mistake the skill exists to prevent.
Also records the two properties a caller can silently get wrong: lookups guard
with `Object.hasOwn` (a bare bracket lookup returns inherited prototype members),
and they resolve unversioned names (246 tools are versioned, and a plain lookup
reports them missing rather than crashing).
* fix(tools): cut the settings-route registry edge and fix serializer test mocks
Two findings from review, both real.
The settings route still reached the registry:
settings/[section]/page.tsx -> settings.tsx -> (dynamic import)
ee/access-control/components/access-control.tsx -> group-detail.tsx
-> tools/utils.ts -> tools/registry.ts
It reads `getTool(id)?.name` — metadata — so it moves to `getToolMetadata`.
The earlier audit missed it because it walked only from the canvas route, and
the edge hides behind a dynamic `import()` that a static walk skips.
Serializer tests mocked the wrong module. `Serializer` now reads params via
`getToolParams` from `@/tools/metadata`, but the tests still only mocked
`@/tools/utils`, so they controlled nothing and passed because the real
generated artifacts happen to agree with the fixtures.
Adds `toolsMetadataMock` to `@sim/testing/mocks`, backed by the same
`mockToolConfigs` as `toolsUtilsMock` so a test mocking both sees one consistent
tool universe, and mocks it in the three serializer suites.
Verified the mock is now load-bearing: pointing it at a sentinel param makes the
three user-only-required validation tests fail, and restoring it returns all 110
serializer tests to green. Before this they passed either way.
* fix(tools): freeze the tool id array handed out by getToolIds
`getToolIds()` returned the module's internal array by reference, so a caller
doing `getToolIds().sort()` would reorder it in place and silently corrupt every
later lookup — the in-place-mutation footgun `.claude/rules/sim-react-performance.md`
calls out.
Frozen rather than copied: the array is consumed in loops, so copying would
allocate on every call. Freezing makes the mutation throw instead of corrupt, and
`[...getToolIds()].sort()` still works. Return type is now `readonly string[]`,
so the mistake is a compile error rather than a runtime surprise.
No caller mutates it today; this is closing the hole, not fixing a live bug.
* test(tools): enforce that the two tool-id resolvers never diverge
`resolveToolId` now exists twice on purpose — `@/tools/utils` resolves against
the live registry (so a tool added before regeneration still resolves at
runtime), `@/tools/tool-ids` against the generated id list (so client code
resolves without importing 4,300 tools). Nothing structurally kept them in step;
a change to versioning logic in one would silently drift from the other.
`tool-metadata:check` now asserts they agree across every id, every stripped
base name, and an unknown — 4,404 probes — and only after the staleness check
passes, so a missing regeneration reports as staleness rather than as drift.
Verified it fails: breaking resolution for `gmail*` exits 1; restoring it passes.
It cannot live in a vitest suite. `vitest.setup.ts` globally mocks
`@/tools/registry` to an empty map, so `getTool` resolves nothing there — a
parity test written as a spec passes or fails for the wrong reason. Both facts
are recorded where the code is.
Both resolvers stay exported. An earlier pass here un-exported the `@/tools/utils`
one as dead; `tools/utils.server.ts` imports it through a multi-line import that
a grep missed, and `tsc` caught it. Its doc now says which resolver a caller
should reach for instead of leaving two identically-named functions unexplained.
Copy file name to clipboardExpand all lines: .agents/skills/tool-registry-boundary/SKILL.md
+25-8Lines changed: 25 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -19,17 +19,20 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec
19
19
20
20
| you need | import | notes |
21
21
| --- | --- | --- |
22
-
| whether a tool id exists |`hasToolMetadata` from `@/tools/metadata`||
23
-
| a tool's params |`getToolParams` / `getToolMetadata` from `@/tools/metadata`||
24
-
| a tool's declared outputs |`getToolOutputsMetadata` from `@/tools/metadata-outputs`| separate module on purpose — see below |
25
-
| every tool id |`getToolIds` from `@/tools/metadata`||
22
+
| whether a tool id exists |`hasToolId` from `@/tools/tool-ids`|~110 KB — the cheapest module |
23
+
| to resolve an unversioned name |`resolveToolId` from `@/tools/tool-ids`||
24
+
| every tool id |`getToolIds` from `@/tools/tool-ids`||
25
+
| a tool's params |`getToolParams` / `getToolMetadata` from `@/tools/metadata`|~4 MB |
26
+
| a tool's declared outputs |`getToolOutputsMetadata` from `@/tools/metadata-outputs`|~4 MB, separate on purpose |
26
27
| to **execute** a tool |`getTool` from `@/tools/utils`, or `@/tools/utils.server`| server paths only |
27
28
28
-
Outputs live in their own module because they are roughly two thirds of the generated data and have a single consumer. Importing `@/tools/metadata` must never pull them — do not "helpfully" re-export one from the other.
29
+
Three modules, cheapest first. Ids are their own artifact because resolution and existence checks need only the key set; outputs are their own because they are the larger half of the data with a single consumer. `@/tools/metadata` and `@/tools/metadata-outputs` both resolve ids through `@/tools/tool-ids`, which is what keeps them independent of each other — do not "helpfully" re-export one from another, or every caller pays for all three.
30
+
31
+
All lookups guard with `Object.hasOwn`. `JSON.parse` yields an object with the normal prototype, so a bare bracket lookup returns inherited members: `getToolMetadata('constructor')` returned a *function* typed as tool metadata before that was fixed.
29
32
30
33
## The generated artifacts
31
34
32
-
`apps/sim/tools/generated/tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
35
+
`apps/sim/tools/generated/tool-ids.ts`, `tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
33
36
34
37
```bash
35
38
bun run tool-metadata:generate # after adding/changing a tool
@@ -43,6 +46,20 @@ Three non-obvious properties, each of which was measured and is easy to undo by
43
46
-**The data is a JSON string parsed at runtime, not an imported `.json` and not an object literal.** With `resolveJsonModule` (which this repo enables), a `.json` import makes TypeScript infer a literal type for all 4,300+ entries and takes `tsc --noEmit` from **12.6s to 8m07s** — a 38x regression. An ambient `declare module` does *not* short-circuit it, and an object literal costs the same. A single string literal is one cheap token for both the compiler and the bundler, and `JSON.parse` beats evaluating the equivalent literal at runtime. Do not "clean this up" into a `.json` import.
44
47
-**The generator refuses to emit function values.** If you add a field to `METADATA_FIELDS` that contains a closure, generation fails loudly rather than shipping executable config to the client. `hosting` and `schemaEnrichment` are excluded for exactly this reason (`hosting.enabled`, `pricing`, and `enrichSchema` are functions) — they are server-only.
45
48
-**Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating.
49
+
-**Lookups resolve versions.**`getTool` maps an unversioned name onto the newest version, and 246 tools are versioned. A plain key lookup would silently report them missing — a quiet correctness bug, not a crash. `resolveToolId` reproduces that against the id set and is differentially tested against the original.
50
+
51
+
## Testing code that reads tool metadata
52
+
53
+
Mock the module the code under test actually reads. `vi.mock('@/tools/utils', () => toolsUtilsMock)` only controls `getTool`; code that reads `params`/`outputs`/`name` goes through `@/tools/metadata`, so mocking `tools/utils` there is a **no-op that still passes** — because the real generated artifacts happen to agree with the mock fixtures. The test looks green while controlling nothing.
vi.mock('@/tools/metadata', () =>toolsMetadataMock) // params / outputs / name
60
+
```
61
+
62
+
Both are backed by the same `mockToolConfigs`, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails.
46
63
47
64
## How to verify an edge actually got cut
48
65
@@ -59,9 +76,9 @@ Reference points measured on this repo:
59
76
|`tools/registry.ts` reachable |~4,900 |
60
77
|`tools/merge-params.ts` (leaf) | 2 |
61
78
|`providers/utils.ts` after cutting its `params` edge | 22 |
62
-
|`app/workspace/[workspaceId]/w/page.tsx` (canvas) | 6,591, of which 4,689 are the registry|
The canvas route reaches the registry through **four** redundant edges — `providers/utils` (via `tools/params`), `lib/workflows/blocks/block-outputs`, `lib/workflows/sanitization/validation`, and `serializer/index`. Cutting any one alone moves the module count by ~1. They must all be cut before anything improves; measure the route, not the file you edited.
81
+
The canvas route reached the registry through **four** redundant edges — `providers/utils` (via `tools/params`), `lib/workflows/blocks/block-outputs`, `lib/workflows/sanitization/validation`, and `serializer/index`. Cutting any one alone moved the module count by ~1. They all had to go before anything improved; measure the route, not the file you edited.
Copy file name to clipboardExpand all lines: .claude/commands/tool-registry-boundary.md
+25-8Lines changed: 25 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -18,17 +18,20 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec
18
18
19
19
| you need | import | notes |
20
20
| --- | --- | --- |
21
-
| whether a tool id exists |`hasToolMetadata` from `@/tools/metadata`||
22
-
| a tool's params |`getToolParams` / `getToolMetadata` from `@/tools/metadata`||
23
-
| a tool's declared outputs |`getToolOutputsMetadata` from `@/tools/metadata-outputs`| separate module on purpose — see below |
24
-
| every tool id |`getToolIds` from `@/tools/metadata`||
21
+
| whether a tool id exists |`hasToolId` from `@/tools/tool-ids`|~110 KB — the cheapest module |
22
+
| to resolve an unversioned name |`resolveToolId` from `@/tools/tool-ids`||
23
+
| every tool id |`getToolIds` from `@/tools/tool-ids`||
24
+
| a tool's params |`getToolParams` / `getToolMetadata` from `@/tools/metadata`|~4 MB |
25
+
| a tool's declared outputs |`getToolOutputsMetadata` from `@/tools/metadata-outputs`|~4 MB, separate on purpose |
25
26
| to **execute** a tool |`getTool` from `@/tools/utils`, or `@/tools/utils.server`| server paths only |
26
27
27
-
Outputs live in their own module because they are roughly two thirds of the generated data and have a single consumer. Importing `@/tools/metadata` must never pull them — do not "helpfully" re-export one from the other.
28
+
Three modules, cheapest first. Ids are their own artifact because resolution and existence checks need only the key set; outputs are their own because they are the larger half of the data with a single consumer. `@/tools/metadata` and `@/tools/metadata-outputs` both resolve ids through `@/tools/tool-ids`, which is what keeps them independent of each other — do not "helpfully" re-export one from another, or every caller pays for all three.
29
+
30
+
All lookups guard with `Object.hasOwn`. `JSON.parse` yields an object with the normal prototype, so a bare bracket lookup returns inherited members: `getToolMetadata('constructor')` returned a *function* typed as tool metadata before that was fixed.
28
31
29
32
## The generated artifacts
30
33
31
-
`apps/sim/tools/generated/tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
34
+
`apps/sim/tools/generated/tool-ids.ts`, `tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
32
35
33
36
```bash
34
37
bun run tool-metadata:generate # after adding/changing a tool
@@ -42,6 +45,20 @@ Three non-obvious properties, each of which was measured and is easy to undo by
42
45
-**The data is a JSON string parsed at runtime, not an imported `.json` and not an object literal.** With `resolveJsonModule` (which this repo enables), a `.json` import makes TypeScript infer a literal type for all 4,300+ entries and takes `tsc --noEmit` from **12.6s to 8m07s** — a 38x regression. An ambient `declare module` does *not* short-circuit it, and an object literal costs the same. A single string literal is one cheap token for both the compiler and the bundler, and `JSON.parse` beats evaluating the equivalent literal at runtime. Do not "clean this up" into a `.json` import.
43
46
-**The generator refuses to emit function values.** If you add a field to `METADATA_FIELDS` that contains a closure, generation fails loudly rather than shipping executable config to the client. `hosting` and `schemaEnrichment` are excluded for exactly this reason (`hosting.enabled`, `pricing`, and `enrichSchema` are functions) — they are server-only.
44
47
-**Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating.
48
+
-**Lookups resolve versions.**`getTool` maps an unversioned name onto the newest version, and 246 tools are versioned. A plain key lookup would silently report them missing — a quiet correctness bug, not a crash. `resolveToolId` reproduces that against the id set and is differentially tested against the original.
49
+
50
+
## Testing code that reads tool metadata
51
+
52
+
Mock the module the code under test actually reads. `vi.mock('@/tools/utils', () => toolsUtilsMock)` only controls `getTool`; code that reads `params`/`outputs`/`name` goes through `@/tools/metadata`, so mocking `tools/utils` there is a **no-op that still passes** — because the real generated artifacts happen to agree with the mock fixtures. The test looks green while controlling nothing.
vi.mock('@/tools/metadata', () =>toolsMetadataMock) // params / outputs / name
59
+
```
60
+
61
+
Both are backed by the same `mockToolConfigs`, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails.
45
62
46
63
## How to verify an edge actually got cut
47
64
@@ -58,9 +75,9 @@ Reference points measured on this repo:
58
75
|`tools/registry.ts` reachable |~4,900 |
59
76
|`tools/merge-params.ts` (leaf) | 2 |
60
77
|`providers/utils.ts` after cutting its `params` edge | 22 |
61
-
|`app/workspace/[workspaceId]/w/page.tsx` (canvas) | 6,591, of which 4,689 are the registry|
The canvas route reaches the registry through **four** redundant edges — `providers/utils` (via `tools/params`), `lib/workflows/blocks/block-outputs`, `lib/workflows/sanitization/validation`, and `serializer/index`. Cutting any one alone moves the module count by ~1. They must all be cut before anything improves; measure the route, not the file you edited.
80
+
The canvas route reached the registry through **four** redundant edges — `providers/utils` (via `tools/params`), `lib/workflows/blocks/block-outputs`, `lib/workflows/sanitization/validation`, and `serializer/index`. Cutting any one alone moved the module count by ~1. They all had to go before anything improved; measure the route, not the file you edited.
Copy file name to clipboardExpand all lines: .cursor/commands/tool-registry-boundary.md
+25-8Lines changed: 25 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -14,17 +14,20 @@ You keep the 4,300-tool executable registry out of module graphs that don't exec
14
14
15
15
| you need | import | notes |
16
16
| --- | --- | --- |
17
-
| whether a tool id exists |`hasToolMetadata` from `@/tools/metadata`||
18
-
| a tool's params |`getToolParams` / `getToolMetadata` from `@/tools/metadata`||
19
-
| a tool's declared outputs |`getToolOutputsMetadata` from `@/tools/metadata-outputs`| separate module on purpose — see below |
20
-
| every tool id |`getToolIds` from `@/tools/metadata`||
17
+
| whether a tool id exists |`hasToolId` from `@/tools/tool-ids`|~110 KB — the cheapest module |
18
+
| to resolve an unversioned name |`resolveToolId` from `@/tools/tool-ids`||
19
+
| every tool id |`getToolIds` from `@/tools/tool-ids`||
20
+
| a tool's params |`getToolParams` / `getToolMetadata` from `@/tools/metadata`|~4 MB |
21
+
| a tool's declared outputs |`getToolOutputsMetadata` from `@/tools/metadata-outputs`|~4 MB, separate on purpose |
21
22
| to **execute** a tool |`getTool` from `@/tools/utils`, or `@/tools/utils.server`| server paths only |
22
23
23
-
Outputs live in their own module because they are roughly two thirds of the generated data and have a single consumer. Importing `@/tools/metadata` must never pull them — do not "helpfully" re-export one from the other.
24
+
Three modules, cheapest first. Ids are their own artifact because resolution and existence checks need only the key set; outputs are their own because they are the larger half of the data with a single consumer. `@/tools/metadata` and `@/tools/metadata-outputs` both resolve ids through `@/tools/tool-ids`, which is what keeps them independent of each other — do not "helpfully" re-export one from another, or every caller pays for all three.
25
+
26
+
All lookups guard with `Object.hasOwn`. `JSON.parse` yields an object with the normal prototype, so a bare bracket lookup returns inherited members: `getToolMetadata('constructor')` returned a *function* typed as tool metadata before that was fixed.
24
27
25
28
## The generated artifacts
26
29
27
-
`apps/sim/tools/generated/tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
30
+
`apps/sim/tools/generated/tool-ids.ts`, `tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
28
31
29
32
```bash
30
33
bun run tool-metadata:generate # after adding/changing a tool
@@ -38,6 +41,20 @@ Three non-obvious properties, each of which was measured and is easy to undo by
38
41
-**The data is a JSON string parsed at runtime, not an imported `.json` and not an object literal.** With `resolveJsonModule` (which this repo enables), a `.json` import makes TypeScript infer a literal type for all 4,300+ entries and takes `tsc --noEmit` from **12.6s to 8m07s** — a 38x regression. An ambient `declare module` does *not* short-circuit it, and an object literal costs the same. A single string literal is one cheap token for both the compiler and the bundler, and `JSON.parse` beats evaluating the equivalent literal at runtime. Do not "clean this up" into a `.json` import.
39
42
-**The generator refuses to emit function values.** If you add a field to `METADATA_FIELDS` that contains a closure, generation fails loudly rather than shipping executable config to the client. `hosting` and `schemaEnrichment` are excluded for exactly this reason (`hosting.enabled`, `pricing`, and `enrichSchema` are functions) — they are server-only.
40
43
-**Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating.
44
+
-**Lookups resolve versions.**`getTool` maps an unversioned name onto the newest version, and 246 tools are versioned. A plain key lookup would silently report them missing — a quiet correctness bug, not a crash. `resolveToolId` reproduces that against the id set and is differentially tested against the original.
45
+
46
+
## Testing code that reads tool metadata
47
+
48
+
Mock the module the code under test actually reads. `vi.mock('@/tools/utils', () => toolsUtilsMock)` only controls `getTool`; code that reads `params`/`outputs`/`name` goes through `@/tools/metadata`, so mocking `tools/utils` there is a **no-op that still passes** — because the real generated artifacts happen to agree with the mock fixtures. The test looks green while controlling nothing.
vi.mock('@/tools/metadata', () =>toolsMetadataMock) // params / outputs / name
55
+
```
56
+
57
+
Both are backed by the same `mockToolConfigs`, so mocking both gives one consistent tool universe. If you are unsure whether a mock is load-bearing, change a fixture value to a sentinel and confirm the test fails.
41
58
42
59
## How to verify an edge actually got cut
43
60
@@ -54,9 +71,9 @@ Reference points measured on this repo:
54
71
|`tools/registry.ts` reachable |~4,900 |
55
72
|`tools/merge-params.ts` (leaf) | 2 |
56
73
|`providers/utils.ts` after cutting its `params` edge | 22 |
57
-
|`app/workspace/[workspaceId]/w/page.tsx` (canvas) | 6,591, of which 4,689 are the registry|
The canvas route reaches the registry through **four** redundant edges — `providers/utils` (via `tools/params`), `lib/workflows/blocks/block-outputs`, `lib/workflows/sanitization/validation`, and `serializer/index`. Cutting any one alone moves the module count by ~1. They must all be cut before anything improves; measure the route, not the file you edited.
76
+
The canvas route reached the registry through **four** redundant edges — `providers/utils` (via `tools/params`), `lib/workflows/blocks/block-outputs`, `lib/workflows/sanitization/validation`, and `serializer/index`. Cutting any one alone moved the module count by ~1. They all had to go before anything improved; measure the route, not the file you edited.
0 commit comments