Skip to content

Commit 07e5f74

Browse files
committed
perf(tools): generate serializable tool metadata artifacts
Adds `scripts/sync-tool-metadata.ts`, which projects the executable tool registry down to the data half nobody needs a closure for, plus typed accessors over the result. No consumer is rewired yet — that is the next PR. `@/tools/registry` is a ~9,000-line barrel over 4,366 tools. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures (`request.headers`, `transformResponse`, `directExecution`, `postProcess`), and those closures reach every integration's SDK client and parser — which is why reaching the barrel costs ~4,700 modules. Every client-reachable caller was audited: none of them need a closure. They need `outputs`, `params`, or an existence check. Two artifacts, not one. `outputs` is ~4 MB of the ~8 MB and has a single consumer, so it is emitted separately and exposed from its own module; callers needing only params never load it. The data is a JSON string parsed at runtime rather than an imported `.json` or an object literal. That is not stylistic — with `resolveJsonModule` (enabled repo-wide) a `.json` import makes TypeScript infer a literal type for all 4,366 entries: tsc --noEmit, baseline 12.6s tsc --noEmit, with `.json` imports 8m07s (38x) tsc --noEmit, with string literals 12.0s An ambient `declare module` does not short-circuit it (measured: 8m18s), and an object literal is the same inference work. A single string literal is one cheap token for the compiler and the bundler, and `JSON.parse` beats evaluating the equivalent literal at runtime. The generator refuses to emit any function value, so shipping executable config to the client fails loudly instead of silently. `hosting` and `schemaEnrichment` are excluded on those grounds — both hold functions and are server-only. Also strips empty param entries: the registry has one (`stt_deepgram_v2`, an `undefined`) which crashes callers that read `param.type` while iterating. `JSON.stringify` drops `undefined` on its own, so the guard is there for an explicit `null` — which serializes faithfully and would reach consumers — and to warn either way. Wires `tool-metadata:check` into CI alongside the other generated-contract gates, and ignores the generated directory in biome (it exceeds the 1 MB limit and was being skipped with a notice on every commit). Adds a `tool-registry-boundary` skill covering which module to import, the three non-obvious properties of the artifacts, and how to verify an edge is actually cut — the canvas route reaches the registry through four redundant paths, so cutting one alone moves the module count by ~1.
1 parent 2772db5 commit 07e5f74

12 files changed

Lines changed: 594 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
name: tool-registry-boundary
3+
description: Keep the executable tool registry out of client-reachable module graphs — when to read `@/tools/metadata` instead of `getTool`, how to measure whether an import edge pulls the registry, and how to regenerate the metadata artifacts. Use when touching `apps/sim/tools/registry.ts`, `tools/utils.ts`, `tools/params.ts`, or anything that calls `getTool`.
4+
---
5+
6+
# Tool Registry Boundary Skill
7+
8+
You keep the 4,300-tool executable registry out of module graphs that don't execute tools.
9+
10+
## The rule
11+
12+
> Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**.
13+
14+
`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules.
15+
16+
`getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in.
17+
18+
## Which module to import
19+
20+
| you need | import | notes |
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` | |
26+
| to **execute** a tool | `getTool` from `@/tools/utils`, or `@/tools/utils.server` | server paths only |
27+
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+
30+
## The generated artifacts
31+
32+
`apps/sim/tools/generated/tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
33+
34+
```bash
35+
bun run tool-metadata:generate # after adding/changing a tool
36+
bun run tool-metadata:check # what CI runs; fails if stale
37+
```
38+
39+
Never hand-edit them. If you add a tool or change a tool's `params`/`outputs`, regenerate and commit the result, or CI fails.
40+
41+
Three non-obvious properties, each of which was measured and is easy to undo by accident:
42+
43+
- **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+
- **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+
- **Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating.
46+
47+
## How to verify an edge actually got cut
48+
49+
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
50+
51+
1. From the entry you care about, follow `import` and `export … from` (skipping `import type`), resolving `@/` against `apps/sim`.
52+
2. Check whether `apps/sim/tools/registry.ts` is in the reachable set, and print the parent chain if it is.
53+
3. Compare the reachable module count before and after.
54+
55+
Reference points measured on this repo:
56+
57+
| entry | modules |
58+
| --- | --- |
59+
| `tools/registry.ts` reachable | ~4,900 |
60+
| `tools/merge-params.ts` (leaf) | 2 |
61+
| `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 |
63+
64+
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.
65+
66+
## When adding a new caller
67+
68+
Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph.
69+
70+
If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
description: Keep the executable tool registry out of client-reachable module graphs — when to read `@/tools/metadata` instead of `getTool`, how to measure whether an import edge pulls the registry, and how to regenerate the metadata artifacts. Use when touching `apps/sim/tools/registry.ts`, `tools/utils.ts`, `tools/params.ts`, or anything that calls `getTool`.
3+
---
4+
5+
# Tool Registry Boundary Skill
6+
7+
You keep the 4,300-tool executable registry out of module graphs that don't execute tools.
8+
9+
## The rule
10+
11+
> Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**.
12+
13+
`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules.
14+
15+
`getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in.
16+
17+
## Which module to import
18+
19+
| you need | import | notes |
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` | |
25+
| to **execute** a tool | `getTool` from `@/tools/utils`, or `@/tools/utils.server` | server paths only |
26+
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+
29+
## The generated artifacts
30+
31+
`apps/sim/tools/generated/tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
32+
33+
```bash
34+
bun run tool-metadata:generate # after adding/changing a tool
35+
bun run tool-metadata:check # what CI runs; fails if stale
36+
```
37+
38+
Never hand-edit them. If you add a tool or change a tool's `params`/`outputs`, regenerate and commit the result, or CI fails.
39+
40+
Three non-obvious properties, each of which was measured and is easy to undo by accident:
41+
42+
- **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+
- **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+
- **Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating.
45+
46+
## How to verify an edge actually got cut
47+
48+
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
49+
50+
1. From the entry you care about, follow `import` and `export … from` (skipping `import type`), resolving `@/` against `apps/sim`.
51+
2. Check whether `apps/sim/tools/registry.ts` is in the reachable set, and print the parent chain if it is.
52+
3. Compare the reachable module count before and after.
53+
54+
Reference points measured on this repo:
55+
56+
| entry | modules |
57+
| --- | --- |
58+
| `tools/registry.ts` reachable | ~4,900 |
59+
| `tools/merge-params.ts` (leaf) | 2 |
60+
| `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 |
62+
63+
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.
64+
65+
## When adding a new caller
66+
67+
Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph.
68+
69+
If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Tool Registry Boundary Skill
2+
3+
You keep the 4,300-tool executable registry out of module graphs that don't execute tools.
4+
5+
## The rule
6+
7+
> Client-reachable code reads tool **metadata**. Only code that actually executes a tool imports the **registry**.
8+
9+
`@/tools/registry` is a ~9,000-line barrel importing every tool. Each `ToolConfig` mixes plain data (`params`, `outputs`, `name`) with closures — `request.url`, `request.headers`, `transformResponse`, `directExecution`, `postProcess`. Those closures reach the SDK clients, API helpers and parsers each integration needs, and that is what makes the barrel expensive: reaching it costs ~4,700 additional modules.
10+
11+
`getTool()` returns the whole `ToolConfig`, so a single `getTool` import anywhere in a client-reachable file drags all of it in.
12+
13+
## Which module to import
14+
15+
| you need | import | notes |
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` | |
21+
| to **execute** a tool | `getTool` from `@/tools/utils`, or `@/tools/utils.server` | server paths only |
22+
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+
25+
## The generated artifacts
26+
27+
`apps/sim/tools/generated/tool-metadata.ts` and `tool-outputs.ts` are produced by `scripts/sync-tool-metadata.ts`:
28+
29+
```bash
30+
bun run tool-metadata:generate # after adding/changing a tool
31+
bun run tool-metadata:check # what CI runs; fails if stale
32+
```
33+
34+
Never hand-edit them. If you add a tool or change a tool's `params`/`outputs`, regenerate and commit the result, or CI fails.
35+
36+
Three non-obvious properties, each of which was measured and is easy to undo by accident:
37+
38+
- **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+
- **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+
- **Empty param entries are stripped.** The registry contains one (`stt_deepgram_v2`), which crashes callers that read `param.type` while iterating.
41+
42+
## How to verify an edge actually got cut
43+
44+
Do not eyeball imports — the registry is reached through several redundant paths, so cutting one buys nothing while another survives. Walk the graph:
45+
46+
1. From the entry you care about, follow `import` and `export … from` (skipping `import type`), resolving `@/` against `apps/sim`.
47+
2. Check whether `apps/sim/tools/registry.ts` is in the reachable set, and print the parent chain if it is.
48+
3. Compare the reachable module count before and after.
49+
50+
Reference points measured on this repo:
51+
52+
| entry | modules |
53+
| --- | --- |
54+
| `tools/registry.ts` reachable | ~4,900 |
55+
| `tools/merge-params.ts` (leaf) | 2 |
56+
| `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 |
58+
59+
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.
60+
61+
## When adding a new caller
62+
63+
Ask what the caller does with the config. If it reads `params`, `outputs`, `name`, `description` or just checks existence, it belongs on `@/tools/metadata` — no exceptions, even on a path you believe is server-only today, because a future client import will silently re-attach the registry to the graph.
64+
65+
If it genuinely executes — builds a request, transforms a response, runs `directExecution` — use `getTool`, and keep that file off client-reachable paths.

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ jobs:
153153
- name: Verify realtime prune graph
154154
run: bun run check:realtime-prune
155155

156+
- name: Verify generated tool metadata is in sync
157+
run: bun run tool-metadata:check
158+
156159
- name: Verify skill projections are in sync
157160
run: bun run skills:check
158161

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

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

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

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

apps/sim/tools/metadata-outputs.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import rawOutputs from '@/tools/generated/tool-outputs'
2+
import type { ToolConfig } from '@/tools/types'
3+
4+
/**
5+
* Declared output shapes for built-in tools, read without importing the
6+
* executable registry. See `@/tools/metadata` for the rationale.
7+
*
8+
* Kept apart from the rest of the metadata because outputs are roughly two
9+
* thirds of the generated data and only block-output inference consumes them —
10+
* callers that need params or an existence check should import `@/tools/metadata`
11+
* and never load this module.
12+
*
13+
* The backing data is generated by `scripts/sync-tool-metadata.ts` and verified
14+
* in CI by `bun run tool-metadata:check`. Never hand-edit it.
15+
*/
16+
type ToolOutputs = NonNullable<ToolConfig['outputs']>
17+
18+
/**
19+
* Annotated rather than inferred: letting TypeScript infer a literal type for a
20+
* 4 MB JSON import makes every downstream file that touches it dramatically more
21+
* expensive to typecheck.
22+
*/
23+
const outputs: Record<string, ToolOutputs> = rawOutputs as Record<string, ToolOutputs>
24+
25+
/** Declared outputs for a built-in tool, or `undefined` if it declares none. */
26+
export function getToolOutputsMetadata(toolId: string): ToolOutputs | undefined {
27+
return outputs[toolId]
28+
}

apps/sim/tools/metadata.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { getToolIds, getToolMetadata, getToolParams, hasToolMetadata } from '@/tools/metadata'
6+
import { getToolOutputsMetadata } from '@/tools/metadata-outputs'
7+
8+
/**
9+
* Guards the properties the generated artifacts are relied on for. The
10+
* `tool-metadata:check` script guards that they are in sync with the registry;
11+
* these guard that what they contain is usable.
12+
*/
13+
describe('generated tool metadata', () => {
14+
it('covers the whole registry', () => {
15+
expect(getToolIds().length).toBeGreaterThan(4000)
16+
})
17+
18+
it('resolves a known tool with its params', () => {
19+
const gmail = getToolMetadata('gmail_send')
20+
expect(gmail?.id).toBe('gmail_send')
21+
expect(Object.keys(gmail?.params ?? {})).toContain('to')
22+
})
23+
24+
it('reports unknown tools as absent without throwing', () => {
25+
expect(hasToolMetadata('definitely_not_a_tool')).toBe(false)
26+
expect(getToolMetadata('definitely_not_a_tool')).toBeUndefined()
27+
expect(getToolParams('definitely_not_a_tool')).toBeUndefined()
28+
expect(getToolOutputsMetadata('definitely_not_a_tool')).toBeUndefined()
29+
})
30+
31+
it('resolves declared outputs for a known tool', () => {
32+
expect(getToolOutputsMetadata('gmail_send')).toBeDefined()
33+
})
34+
35+
/**
36+
* The registry contains a null param entry (`stt_deepgram_v2`), which crashes
37+
* any consumer that iterates params unguarded. The generator strips those, so
38+
* consumers may iterate freely.
39+
*/
40+
it('contains no null param entries', () => {
41+
for (const id of getToolIds()) {
42+
for (const [paramId, config] of Object.entries(getToolParams(id) ?? {})) {
43+
expect(config, `${id}.${paramId} is empty`).not.toBeNull()
44+
expect(config, `${id}.${paramId} is empty`).toBeDefined()
45+
}
46+
}
47+
})
48+
49+
/**
50+
* The whole point of the artifacts: they carry no executable config, so
51+
* importing them cannot pull the tool implementations into a module graph.
52+
*/
53+
it('contains no function values', () => {
54+
for (const id of getToolIds()) {
55+
const metadata = getToolMetadata(id)
56+
for (const [key, value] of Object.entries(metadata ?? {})) {
57+
expect(typeof value, `${id}.${key} is a function`).not.toBe('function')
58+
}
59+
for (const [paramId, config] of Object.entries(metadata?.params ?? {})) {
60+
for (const [key, value] of Object.entries(config ?? {})) {
61+
expect(typeof value, `${id}.params.${paramId}.${key} is a function`).not.toBe('function')
62+
}
63+
}
64+
}
65+
})
66+
})

0 commit comments

Comments
 (0)