Skip to content

perf(cli): move the compiler and tokenizer out of the executable - #4469

Draft
Daksh (sudodaksh) wants to merge 2 commits into
claude/cli-lazy-heavy-imports-c7xiczfrom
claude/cli-startup-bundle-diet-c7xicz
Draft

perf(cli): move the compiler and tokenizer out of the executable#4469
Daksh (sudodaksh) wants to merge 2 commits into
claude/cli-lazy-heavy-imports-c7xiczfrom
claude/cli-startup-bundle-diet-c7xicz

Conversation

@sudodaksh

@sudodaksh Daksh (sudodaksh) commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

composio --version: 288ms to 199ms. Peak RSS: 97.8MB to 77.3MB. Executable: 85.9MB to 79.7MB. Every command benefits.

A compiled Bun binary parses its whole embedded bundle before running any JS. #4468 stopped the TypeScript compiler and the tokenizer rank table from being evaluated at startup, but they were still parsed every time. The compiler was 44% of the executable's JavaScript, the o200k table another 28%. Both now ship as files next to the executable and load on demand.

Fourth PR in the stack. Stacked on #4468; review #4463, #4464 and #4468 first. #4475 builds on this one.

Bun 1.4.1+4661e494f, linux-x64, best of 15, telemetry disabled, both binaries built in the same session:

before (#4468) after
composio --version 288ms 199ms
composio tools execute --help 287ms 202ms
peak RSS 97.8MB 77.3MB
executable 85.9MB 79.7MB
executable JS, minified 8.3MB 2.1MB

Across the whole stack, from next: --version 612ms to 184ms, peak RSS 167MB to 78MB, executable 95.8MB to 79.7MB.

composio execute end to end, against the live backend with a logged-in CLI, best of 7 for the small response and best of 5 for the large one. Tool: HACKERNEWS_GET_ITEM_WITH_ID (no connected account needed) and HACKERNEWS_GET_LATEST_POSTS. "Tail" is the time from the execute.tool_call.end perf event to process exit.

next #4468 this PR
1.6KB response, wall 2431ms 1857ms 1761ms
1.6KB response, tail 294ms 12ms 11ms
35KB response, wall 2665ms 2210ms 2165ms
35KB response, tail 322ms 329ms 353ms

The stack removes ~670ms from a small execute: ~430ms of startup and ~280ms of tokenizer construction that no longer happens. The large response keeps its ~330ms tail because past 10KB the tokenizer is still built; this PR adds ~20ms there for the on-demand parse of the encoder file. The remaining ~1.7s is network the stack does not touch: DNS and TLS to the backend, the preflight round trips before tool_call.start, and the session create plus execute pair. Wall times move by ±150ms between runs because of that; the tail column is the stable one.

Changes

  1. generation-runtime.mjs carries src/generation/*, the composio run source rewrites, typescript, @composio/ts-builders and openapi-typescript. generate ts, generate py and run load it with the new loadInstalledCompanionModule. From a source checkout the loader resolves the .ts file next to run-companion-modules.ts, so tests and bun run src/bin.ts need no build step. The specifier is computed at runtime on purpose; a literal import('./x') gets folded back into the executable. A file that fails to import is a typed RunCompanionRepairError asking to reinstall, not a crash. Both companions are also tsdown entries, so the dist/ build resolves them.
  2. execute-output-encoder-runtime.mjs carries js-tiktoken/lite plus the rank table. execute loads it only past the 10KB byte gate from perf(cli): cut 221ms and 44MB RSS off every CLI invocation #4463, and never for executes started by composio run. If it cannot be loaded, even after the self-repair download, execute estimates the token count from the byte length (about four bytes per token) instead of failing a tool call that already succeeded.
  3. Both join RUN_COMPANION_MODULE_BASENAMES, the mechanism composio run already uses for its helpers, so build, release packaging, install and upgrade verification, and the self-repair download pick them up unchanged. The three hand-maintained uninstall lists and the upgrade E2E fixture gain the two file names.
  4. A companion bundles its own copy of effect, and a fiber cannot run primitives from another copy. So nothing Effect-shaped crosses the boundary. The generation companion exposes plain promises and returns failures as values. src/generation/errors.ts rebuilds them as the CLI's own error classes with fields and stack intact.
  5. src/constants.ts imported constants from @composio/core's root entry for two strings and two URLs, which evaluated the whole SDK at startup (~25ms, mostly zod schemas). The values are inlined and a test pins them to core's. tool-file-uploads.ts imports its three core helpers on the upload path instead of at module scope.
  6. Build guard. After building the companions, the build bundles src/bin.ts once more, unminified, and fails if the executable's graph reaches typescript, js-tiktoken, src/generation/* or a companion entry. Checked that it fires on a stray static import. @composio/core's root entry is not on the list: it is still bundled behind the file-upload path's dynamic import (see Additional context), so the guard cannot exclude it.

What changes for users:

  • A damaged install (companion file missing) now affects generate the way it already affected run: self-repair from the release archive, then an error. A large execute also attempts the repair, and if that fails it falls back to the byte-based estimate rather than failing. Responses under 10KB never touch the encoder. --version and everything else are unaffected.
  • composio upgrade from a binary older than this PR copies only the companion files that binary knows about. The first generate, run or large execute on the new version then restores the two new files through the self-repair download.
  • execute responses over 10KB pay ~20ms more after execute.tool_call.end (351 to 374ms), the on-demand parse of the 2.2MB encoder file. Under 10KB, unchanged.
  • Errors from generation are rebuilt instances. Same class, tag, fields, message and stack; different object identity.

Generated output is byte-identical to #4468 for generate ts, generate ts --transpiled and generate py. The 11-invocation help/error diff from #4468 is identical.

Found on the way: assertBundledRuntimeFiles blanked string literals to same-length runs of spaces, and the import patterns' ^\s* then backtracked quadratically over the compiler's embedded lib strings. The build hung for over ten minutes. String bodies are dropped now. The check has also never matched a specifier, since the specifiers it looks for are the strings it removes. Left as is, because a corrected version flags false positives in run-subagent-output-mcp.

Type of change

  • Bug fix
  • New feature
  • Refactor/Chore
  • Documentation
  • Breaking change

How Has This Been Tested?

Bun 1.4.1+4661e494f, Node 24.20.0, pnpm 11.8.0, linux-x64.

  1. cd ts/packages/cli && pnpm run typecheck && pnpm run validate:boundaries && pnpm run validate:skills
  2. pnpm exec vitest run: 129 files, 1335 passed, 1 skipped. New tests cover the mirrored constants, error rehydration and outcome lifting, and the loader resolving both companions from source.
  3. pnpm build:binary, then against dist/composio: generate ts, generate ts --transpiled and generate py diffed against perf(cli): defer the TypeScript compiler and generation pipeline #4468's binary, run with a trailing expression, execute with 1.6KB and 35KB responses, and the damaged-install cases with files deleted from dist/.
  4. Docker E2E on this branch: upgrade 2 pass, run 8 pass, version 9 pass, install 7 pass on bash and 5 pass on zsh. The install runs used a fixture built the way CI builds it (build:binary:cross, build:binary:package, build:binary:checksums), which also confirms the release zip carries both new files.
  5. bun run test/release-workflow.test.ts at the repo root, for the synced uninstall lists.
  6. Follow-up commit (encoder fallback, typed load failure, graph-check and tsdown fixes): pnpm run typecheck, validate:boundaries and oxlint pass. The execute, companion-loader, constants, generation-runtime, run and generate suites pass (177 passed, 1 skipped), including new tests for the estimate when the encoder cannot load and for the typed load failure. The fallback test fails without the fix. pnpm build emits both companions, and pnpm build:binary passes the graph check.

Screenshots (if applicable)

Not applicable.

Checklist

  • I have read the Code of Conduct and this PR adheres to it
  • I ran linters/tests locally and they passed
  • I updated documentation as needed
  • I added tests or explain why not applicable
  • I added a changeset if this change affects published packages

@composio/cli is private, so no changeset. Docs: the uninstall lists and the code generation section of ts/packages/cli/AGENTS.md.

Additional context

openai and pusher-js (~0.5MB minified) are still in the executable. Only @composio/core's root entry reaches them, and the two upload guards have no lighter subpath export. A @composio/core/utils/file-upload-guard entry would remove them; that is a core package change.

Companion files carry no version stamp, so a companion left from another version is loaded as is. Checking APP_VERSION after import would catch that; it is not done here.

The remaining ~95ms of module evaluation is a long tail of eager Schema and command definitions across src/commands, src/services, effect and src/models, not one dependency.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wx9gEjuiHux2weiHjdNcDs

@vercel

vercel Bot commented Sep 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs Ready Ready Preview Sep 14, 2026 12:10pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Preview this PR's installer

The hermetic install E2E suite passed for this commit. These commands run the PR's installer scripts against the latest published CLI release — they preview installer behavior, not unreleased binaries.

curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install.sh" | sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install.sh" | COMPOSIO_INSTALL_SHELL=none sh

The first command is the default flow: it installs the CLI and configures your shell automatically. The second installs only, without touching shell startup files.

Shell-specific setup routes (the override points the variant at this PR's base installer):

curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install/bash.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install.sh" sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install/zsh.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install.sh" sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install/fish.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/96749f5aaae7be1d60c801cfd0d1cc36608a9f34/install.sh" sh

DakshM on Exe (exe.dev) and others added 2 commits September 14, 2026 14:05
`composio --version` goes from 288ms to 199ms, peak RSS from 97.8MB to
77.3MB, and the executable from 85.9MB to 79.7MB. Every command benefits.

A compiled Bun binary parses its whole embedded bundle before the first
line of JavaScript runs, and #4468 had already made sure the TypeScript
compiler and the tokenizer rank table were never *evaluated* unless
`generate`, `run`, or a large `execute` response needed them. They were
still *parsed* on every start: the compiler alone was 44% of the
executable's JavaScript and the o200k rank table another 28%, so
`--version` spent ~75ms reading code it could never call.

Both now ship as companion modules next to the executable, through the
mechanism `composio run` already uses for its own runtime helpers:

- `generation-runtime.mjs` carries `src/generation/*`, the `composio run`
  source rewrites, `typescript`, `@composio/ts-builders` and
  `openapi-typescript`. `generate ts`, `generate py` and `run` load it
  with `loadInstalledCompanionModule`; from a source checkout the loader
  resolves the `.ts` next to `run-companion-modules.ts` instead, so tests
  and `bun run src/bin.ts` need no build step.
- `execute-output-encoder-runtime.mjs` carries `js-tiktoken/lite` and the
  rank table. `execute` loads it only once a response exceeds the 10KB
  byte pre-filter.

A companion bundles its own copy of `effect`, and a fiber cannot run
primitives built by another copy of the runtime, so nothing Effect-shaped
crosses the boundary: the generation companion exposes plain functions
and promises, runs its pipelines on its own runtime, and returns failures
as values that `src/generation/errors.ts` rebuilds as the CLI's own error
classes, stack included. Generated output is byte-identical to #4468 for
`generate ts`, `generate ts --transpiled` and `generate py`.

Both modules join `RUN_COMPANION_MODULE_BASENAMES`, so the build, release
packaging, install verification, `upgrade` and the self-repair download
pick them up unchanged. The three hand-maintained uninstall lists and the
upgrade E2E fixture gain the two file names.

Two smaller startup costs go with it:

- `src/constants.ts` imported `constants` from `@composio/core`'s root
  entry for two strings and two URLs, which evaluated the whole SDK at
  startup (~25ms of module-scope work, mostly zod schemas). The four
  values are spelled out and pinned to core's by a test.
- `tool-file-uploads.ts` imported three core helpers at module scope that
  only a file upload reaches; they are imported on that path now.

The binary build gains a guard: after bundling the companions it bundles
`src/bin.ts` once more unminified and fails if the executable's graph
reaches `typescript`, `js-tiktoken`, core's root entry, `src/generation/*`
or a companion entry. Without it a stray static import would put the
compiler back into the executable with nothing to notice.

Building also surfaced that `assertBundledRuntimeFiles` blanked string
literals to same-length runs of spaces, which made the import patterns'
`^\s*` backtrack quadratically across the compiler's multi-megabyte
embedded lib strings and stalled the build for over ten minutes. String
bodies are dropped now. (The check itself has never matched a specifier,
since the specifiers it looks for are the string literals it removes;
that is left as it was.)

Measured on the pinned toolchain, Bun 1.4.1+4661e494f, linux-x64, best
of 15, telemetry disabled, both binaries built in the same session:

  composio --version       288ms -> 199ms
  tools execute --help     287ms -> 202ms
  peak RSS                 97.8MB -> 77.3MB
  executable               85.9MB -> 79.7MB
  executable JavaScript    8.3MB -> 2.1MB (minified)

The `execute` tail after `execute.tool_call.end` is unchanged for
responses under 10KB (~10ms) and ~20ms slower above it (351 -> 374ms),
which is the on-demand parse of the 2.2MB encoder companion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx9gEjuiHux2weiHjdNcDs
…s to load

A large execute measured its output after the tool call had already succeeded,
so a missing encoder companion whose repair download failed turned a successful
call into a failed command. Fall back to a byte-based token estimate instead.

Load companion modules with Effect.tryPromise so an unloadable file is a typed
RunCompanionRepairError rather than a defect, and add the two companions as
tsdown entries so the dist build can resolve them.

The executable graph check listed @composio/core's root entry with a pattern
that could never match Bun's relative module paths. The root entry is still
bundled behind the file-upload dynamic import, so drop that entry and correct
the constants comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants