This PR:
- builds on top of https://github.com/ComposioHQ/composio/pull/4475
- stores `composio execute` responses over 40,000 UTF-8 bytes in a
session file, replacing the check for more than 10,000 o200k tokens
added in https://github.com/ComposioHQ/composio/pull/2979
- keeps `tokenCount` in the stored-output summary as an estimate
(`ceil(sizeBytes / 4)`), adds `sizeBytes`, and logs `Response stored in
<path> (N KB, ~M tokens)`
- removes `js-tiktoken` and the `execute-output-encoder-runtime`
companion from https://github.com/ComposioHQ/composio/pull/4469, with
its tsdown entry, build-guard and startup-import patterns, upgrade
fixture entry, and the encoder fallback and special-token tests
- keeps `execute-output-encoder-runtime.mjs` in the three uninstall
lists, so installs that shipped it can still remove the leftover file
- behavior change: a 10–40KB response of more than 10,000 tokens now
prints inline, and dense non-ASCII output (about 1–2 bytes per token)
can print inline at up to ~20,000 tokens
- verified: CLI typecheck, full CLI vitest (131 files, 1343 passed, 1
skipped), `test/release-workflow.test.ts`, and
`build-companion-modules.ts` with the executable graph check
## Context
Nothing reads `tokenCount` as a number: it appears only in the
stored-output summary, the two log lines, session history, and a debug
log in `run-helpers-runtime.ts`. o200k is not the tokenizer of the model
that reads the output, so the exact count only moved the cutoff. It cost
the 2.3MB rank table, ~330ms after large tool calls (measured in #4469,
not re-measured here), and a separately shipped companion whose absence
made `composio run` require a repair download after an upgrade.
## Summary
`PusherService.subscribe` binds `pusher:subscription_error` after the
Pusher subscription call returns. `pusher-js` dispatches this event
asynchronously without catching listener exceptions, so authentication,
permission, server, or network subscription failures could escape as
uncaught exceptions in Node applications.
Fixes#4445
## Changes
- Log asynchronous Pusher subscription errors at the SDK error boundary
instead of throwing from the event callback.
- Add regression coverage that emits `pusher:subscription_error` after
`subscribe()` resolves and verifies that it does not throw.
- Add a patch changeset for the fixed `@composio/core`/`@composio/slim`
package group.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- Node `v24.17.0` / pnpm `11.8.0`
- `pnpm --filter @composio/core exec vitest run
test/services/pusher.test.ts test/utils/pusher.test.ts` — 2 files, 5
tests passed
- `pnpm --filter @composio/core test` — 55 test files passed; 1,289
tests passed and 2 existing tests reported expected failures; command
exited successfully
- `pnpm --filter @composio/core typecheck`
- `pnpm lint` — passed with existing repository warnings
- `pnpm validate:changesets`
- `pusher-js` `v8.6.0` runtime probe confirmed that an exception thrown
from a `pusher:subscription_error` listener reaches Node's
`uncaughtException` handler; the regression test verifies the SDK
callback no longer throws.
## Screenshots (if applicable)
Not applicable.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [x] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [x] I added a changeset if this change affects published packages
## Additional context
This patch is intentionally limited to the live `PusherService` path.
XHR timeout handling is a separate concern and is not included here. The
older unreferenced `PusherUtils` helper is unchanged to keep this fix
scoped to the path used by `Triggers`.
The containment around the subscription error callback only caught
synchronous throws. TypeScript accepts an async function against a
void-returning callback type, so a rejected promise escaped as an
unhandled rejection after subscribe() resolved - on modern Node that
can terminate the process, the same failure class this PR contains.
Route the callback result through Promise.resolve(...).catch so both
sync throws and async rejections land in the same contained logger
path, and add a regression test for the async case.
Addresses greptile-apps P1 and Cursor Bugbot review comments.
The handleAssistantMessage, waitAndHandleAssistantStreamToolCalls, and
waitAndHandleAssistantToolCalls methods target the OpenAI Assistants
API, which shuts down on August 26, 2026. Add a deprecation warning
pointing new flows at OpenAIResponsesProvider.
- Use gpt-5 in the Responses API examples; gpt-4 predates the Responses
API and the repo's other Responses examples use gpt-5.
- Print response.output_text instead of indexing into content items,
which assumes non-empty message content.
- Add the OpenAIResponsesProvider type surface to the Type Definitions
section, which previously only showed the chat completions provider.
- PusherService.subscribe and Triggers.subscribe accept an optional
onSubscriptionError callback invoked with the raw pusher
pusher:subscription_error payload, giving hosts a programmatic signal
for post-resolution subscription failures (previously log-only).
- Exceptions thrown from the callback are contained and logged, never
rethrown, so a faulty handler cannot crash the host.
- The parameter is optional; existing callers are unaffected.
- Document the new parameter in the TypeScript triggers reference and
the subscribing-to-events guide; bump the changeset to minor for the
new API surface.
Applies review finding #1 from the PR #4448 review.
- Log the full pusher:subscription_error payload (type, error, status)
instead of a flattened String(data.error) so operators can tell auth
failures from permission failures.
- Log the subscription success message only when Pusher dispatches
pusher:subscription_succeeded, not when subscribe() returns.
- Extend regression coverage for both behaviors.
Applies review findings #2 and #3 from the PR #4448 review.
Replacing the pass-through with an eager projection changed the failure mode for
an auth config detail that omits a field group. Passing the group through left
`undefined` in place, which zod rejected as a handled validation error;
`transformToolkitAuthFieldGroup` instead reads `group.required`, so the same
response now throws a `TypeError` before validation runs and crashes
`toolkits.get()`.
Default a missing or null group, and a missing list inside a group, to empty
lists. This repo's docs pipeline already assumes that shape: the schemas in
`docs/lib/toolkit-api.ts` carry `.catch({ required: [], optional: [] })` on the
same fields. Normalizing also keeps the other group usable when only one is
absent, which is typically the one the caller asked for, where a validation
error would have returned nothing.
Reported by greptile-apps on #4411.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- listen: replace `Effect.catchAll`, which no longer exists in
effect@4.0.0-rc.112, with `Effect.result` and narrow the lookup failure
with `NotFoundError` from @composio/client instead of a structural
status check.
- tests: `it.scoped` is gone from @effect/vitest v4 (`it.effect` already
provides a Scope); switch the new cases over and assert the exit code on
`triggers info` too, so the shared handler's contract is covered by more
than one command.
- handle-http-error: also print the hint plainly when stderr is not a TTY.
- changelog: record the four user-visible fixes.
Claude-Session: https://claude.ai/code/session_01SvfF1wniMJsBDo5PaBjm2Y
Whether `total_items` honors the `statuses` filter is a server detail. A
count that did not would make the shared list look truncated on every call
and quietly route the account picker onto the second request forever. Take
a page as complete when it is shorter than the requested size and carries
no cursor, and cover both the short-page and full-page cases.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
The derived toolkit filter lower-cased only, while the grouping helpers in
the same module trim first and the fallback sent the raw slug to the server.
A padded `--toolkit` value could therefore match or miss depending on how
many accounts the user has. Trim once, compare with the shared normalizer,
and send the trimmed slug on the fallback. Drop the optional chaining on
fields the client types as required.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
`resolveCommandProject` gives `composio execute` the consumer project's
nano id, while the cache refresh asked `getFor` for a client keyed on the
long id. Two clients meant two memo keys, so the connected-account list
was still fetched twice per consumer execute. Use the same id on both.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
The memoized `get_latest_version` is keyed on slug, org, and project. The
command's version check passed the resolved project while the executor's
schema lookup passed nothing, so the two never shared a key and
`composio execute` still issued both requests. Hand the executor the
project scope the command resolved.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
The user context is live state that `login` fills in during the same
process. Memoizing the `null` answered before that would skip the version
check for that tool for the rest of the run. Resolve the key outside the
memo and cache only real lookups.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
A memoized tool version or connected-account list lives for the whole
process, so within one vitest file the first case that lets the lookup
succeed would hand its answer to every later case sharing the key. Expose
`clear()` on each memo plus a process-wide `clearInProcessMemos`, and call
the latter from the shared vitest setup before every test.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
`Effect.cached` makes the first caller the owner of the shared run, so
interrupting that caller stores the interrupt in the cell and replays it to
every fiber already waiting on it. On `composio execute --parallel` two specs
sharing a slug could take the second one down when the first one's
`Effect.all` failed on the tool-detail fetch.
Run the memoized effect on a detached fiber that completes a Deferred, so a
caller's interruption cancels only its own wait.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
memoizeInProcess evicted a key only on typed failures, but Effect.cached stores
the whole Exit, so a defect or an interrupted first caller was replayed for the
rest of the process. Evict on any failure cause.
ActiveConnectedAccountsListError was only ever unwrapped to its cause by both
callers, so the shared list now fails with the raw rejection.
`composio execute` listed the user's connected accounts twice on every
call: once toolkit-filtered by the account picker, once unfiltered by
session creation. The two code paths cannot see each other. The
unfiltered list is now fetched once per process and shared, and the
picker derives its toolkit subset from it: same slug match, server order
kept, first 100, exactly what its own query returned. If the shared list
is truncated (more active accounts than one page), the picker falls back
to its original request, so results are identical in every case.
Interleaved A/B against the parent commit, compiled binaries, 15 runs
each on a small response: 1735 -> 1636ms best, 1934 -> 1845ms median.
One fewer request on the critical path, ~140ms.
`memoizeInProcess` memoizes an Effect per key for the process lifetime,
shares one run between concurrent callers, and drops failures so the next
caller retries. It also covers `get_latest_version`, which the definition
refresh fetched twice with the same key on the stale path. The executor's
own version lookup stays unscoped and separate; scoping it to org and
project would change which definition it resolves, so that is left as is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx9gEjuiHux2weiHjdNcDs
`composio execute` stored a response in a session file once js-tiktoken
counted more than 10,000 o200k tokens. Nothing reads that count as a number,
and o200k is not the tokenizer of the model reading the output, so the exact
count only moved the cutoff while costing the 2.3MB rank table, ~330ms after
large tool calls, and a separately shipped companion file with its own repair
path.
Responses over 40,000 UTF-8 bytes are stored now. The summary keeps
`tokenCount`, now estimated at four bytes per token, and gains `sizeBytes`.
The execute-output-encoder-runtime companion, js-tiktoken, and their build,
packaging and test entries are removed. The uninstall lists keep the
companion's file name so installs that shipped it can still remove it.
Bun keeps a failed or already-loaded import in its module registry, so
importing a companion again after a repair can still see the missing
dependency or the old module. Repair now runs before the single import, and
only when the requested companion's wrapper or its import graph is missing,
so unrelated missing companions still cannot block it.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
- Import an in-process companion before checking the rest of the set, so a
missing unrelated companion cannot fail generate or run through an offline
repair. Repair runs only when the requested module fails to load.
- Check each companion's required exports, so a file left by another release
fails with a typed reinstall error instead of calling a missing export.
- When the tokenizer cannot load, store any response past the byte pre-filter.
The four-bytes-per-token estimate can undercount, so it no longer keeps a
response inline.
- Bundle the executable graph check with the release build's env inlining,
defines, NODE_ENV and syntax minification.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
Resolve the generate and run loaders in favor of the generation companion, drop the entry modules it supersedes, and let the startup-imports test allow src/generation/errors.ts as the binary build guard does.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
The hung-native-host test forked setup, yielded once, then advanced the
TestClock by two minutes. Setup does real file I/O before it reaches the
host command, so on a slow CI worker the timeout was not yet armed when
the clock moved; the sleep then waited forever and the test failed on
vitest's own 15s limit. It flaked on next-based branches today, not
only this one. The hanging runner now opens a latch when setup reaches
it, and the test advances the clock only after that.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
A static import of typescript or src/generation anywhere on the command
tree silently restores ~165ms of module evaluation to every invocation,
and nothing failed. The test loads the command tree in a fresh Bun
process and asserts, via the module registry, that neither the compiler
nor the generation pipeline nor the run source transforms were
evaluated. Verified it fails on a stray static import.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
The generate handlers assembled their pipeline from three or four dynamic
imports and repackaged the results by hand. Each pipeline now has an
index module that re-exports what the handler needs, so the deferred
load is a single import and the boundary the handler crosses is named.
Claude-Session: https://claude.ai/code/session_01MTb47vexN35pLGsSZwBmrJ
memoizeInProcess evicted a key only on typed failures, but Effect.cached stores
the whole Exit, so a defect or an interrupted first caller was replayed for the
rest of the process. Evict on any failure cause.
ActiveConnectedAccountsListError was only ever unwrapped to its cause by both
callers, so the shared list now fails with the raw rejection.
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.
`composio execute` listed the user's connected accounts twice on every
call: once toolkit-filtered by the account picker, once unfiltered by
session creation. The two code paths cannot see each other. The
unfiltered list is now fetched once per process and shared, and the
picker derives its toolkit subset from it: same slug match, server order
kept, first 100, exactly what its own query returned. If the shared list
is truncated (more active accounts than one page), the picker falls back
to its original request, so results are identical in every case.
Interleaved A/B against the parent commit, compiled binaries, 15 runs
each on a small response: 1735 -> 1636ms best, 1934 -> 1845ms median.
One fewer request on the critical path, ~140ms.
`memoizeInProcess` memoizes an Effect per key for the process lifetime,
shares one run between concurrent callers, and drops failures so the next
caller retries. It also covers `get_latest_version`, which the definition
refresh fetched twice with the same key on the stale path. The executor's
own version lookup stays unscoped and separate; scoping it to org and
project would change which definition it resolves, so that is left as is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wx9gEjuiHux2weiHjdNcDs
`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
`composio --version` drops from 622ms to 408ms, and eager module evaluation
from 363.8ms to 130.0ms, measured on the pinned toolchain (Bun
1.4.1+4661e494f, linux-x64, best of 7, analytics disabled).
`commands/index.ts` builds the root command tree from every `.cmd.ts` module,
so evaluating any one command evaluated all of them. Two of those modules
reached the TypeScript compiler and the code generation pipeline, which
nothing but `composio generate` and `composio run` ever calls:
155.8ms -> 8.0ms commands/run.cmd
63.5ms -> 2.5ms commands/generate
The command tree itself is untouched. `Command.withHandler(input => Effect)`
already runs lazily, so moving these imports inside the handler bodies is
enough. Specs, flags, descriptions, subcommand wiring and `root-help.ts`
introspection all still resolve eagerly, which is why parsing, help rendering
and "did you mean" suggestions cannot shift.
run.cmd.ts was the CLI's only consumer of `import ts from 'typescript'`,
through three source rewrites that `composio run` applies to a user script.
Those move to `run-source-transforms.ts`, which the handler imports
dynamically. The test suite imports them from the new path.
ts.generate.cmd.ts and py.generate.cmd.ts pulled `src/generation/*` at module
scope. Both now resolve it inside the handler, immediately before the first
use.
A rejected import of a module bundled into this binary is an impossible
invariant rather than a recoverable failure, so these use `Effect.promise`
rather than `Effect.tryPromise`. The module registry memoizes each import, so
repeat calls within one run cost nothing.
Behavior is unchanged, checked rather than assumed. Eleven invocations,
covering `--help` at root and for generate, generate ts, generate py, run,
tools and execute, plus `version`, `--version`, an unknown command and an
unknown flag, produce byte-identical stdout, stderr and exit codes before and
after.
Verified with typecheck (src and test), oxlint, validate:boundaries,
validate:skills, and the full package suite: 1326 passed, 1 skipped.
`test/src/cli-main.test.ts` times out in this container and does so identically
on the parent commit (25.6s and 25.2s there, 24.5s and 24.3s here) because it
spawns the CLI from source against a 15s timeout. Not caused by this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzaE7oGVgziJ5nRvBhcci2
encode(json, 'all') turns each literal into one special token rather than
counting its characters as text, so say that in the comment and the test name.
`Tiktoken.encode` signs as `encode(text, allowedSpecial = [],
disallowedSpecial = "all")`. The CLI passed only the text, so every special
token was disallowed and the call threw on any response containing the
literal `<|endoftext|>` or `<|endofprompt|>`. A 66-byte payload is enough.
Reading a README that documents a tokenizer hits it.
The throw landed in `prepareExecuteOutput`, after `spinner.stop('Execution
successful')` had already printed. So the tool had run, its side effects had
happened, and the CLI still exited 1 with nothing on stdout and no session
history entry.
Here the encoder is only a length gauge for the inline-versus-file decision,
so those literals are ordinary characters. Passing `allowedSpecial: 'all'`
counts them instead of rejecting the payload. Token counts are unchanged on
text that contains no special tokens.
The preceding commit's byte-length pre-filter hid this below 10KB by
skipping the encoder. Larger responses still reached it, and both call sites
were affected: the threshold check and the `tokenCount` reported for a
stored file. Both now go through one `countOutputTokens` helper.
The regression test drives the real command with a response holding both
literals, sized past the inline threshold so the count is actually computed.
Verified it fails without the fix, with the original error:
Error: The text contains a special token that is not allowed: <|endoftext|>
Verified with typecheck (src and test), oxlint, validate:boundaries, and the
tools.execute and run command suites, now 90 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzaE7oGVgziJ5nRvBhcci2
Check the invocation origin before building the tokenizer, since composio run
always prints inline, and pass the token count from the threshold check into
the stored-output summary instead of encoding the payload twice.
Add a test for a response past the byte pre-filter but under the token
threshold, and drop the unused ts-morph dependency.
A compiled Bun binary parses its whole embedded module graph before the
first line of JS runs, so bundled-but-unused code is paid for on every
invocation. Verified: a binary that bundles everything but evaluates only
console.log still costs ~235ms, against 15ms for a hello-world build.
Most of this bundle was code `composio execute` never reaches.
Measured on the pinned toolchain (Bun 1.4.1+4661e494f, linux-x64,
best of 7, analytics disabled):
composio --version 970ms -> 749ms (-221ms)
peak RSS 175.8MB -> 132.2MB (-43.6MB)
compiled binary 96MB -> 86MB
bundle 31.24MB -> 15.84MB
Four changes.
run.cmd.ts imported `ts` from ts-morph, which vendors its own copy of the
TypeScript compiler, so the binary carried two of them. The file uses only
createSourceFile, forEachChild, ScriptTarget, ScriptKind and five isX
guards, all available in the typescript copy that
src/generation/typescript/* already pulls in. Sharing one compiler also
means commands/generate and commands/run.cmd no longer evaluate a compiler
each.
js-tiktoken's main entry statically inlines all six BPE rank tables (gpt2,
r50k, p50k, p50k_edit, cl100k, o200k) as string literals; the CLI only ever
uses o200k, via encodingForModel('gpt-4o'). The lite build with that single
table produces identical token-id streams.
prepareExecuteOutput built the o200k rank table on every successful
execute, purely to compare the response against a 10k-token threshold.
Constructing it measured ~390ms in a compiled binary on the pinned
toolchain (390.1, 367.1, 418.6ms across three runs), against ~4ms to
encode a 7.5KB payload once the table exists. A BPE token always covers at
least one UTF-8 byte, so a payload of at most THRESHOLD bytes can never
exceed THRESHOLD tokens; checking byte length first reaches the same
decision without the tokenizer.
That ~390ms is construction cost measured in isolation, not an end-to-end
delta on a real `composio execute`. No credentialed run was available to
measure the whole command before and after, so treat it as the size of the
work removed from the success path rather than a verified wall-clock
saving. `COMPOSIO_PERF_DEBUG=1` reports the gap between
`execute.tool_call.end` and exit for anyone able to run it for real.
ToolsExecutorLive resolved a client through clientSingleton.get()
unconditionally, walking the project context off disk, then discarded it
because every caller on the remote-execute path passes one in.
Three behavioral deltas, none of them the tokenization result or the
inline/file decision:
1. Tiktoken.encode() throws on the literals <|endoftext|> and
<|endofprompt|> appearing anywhere in the response, at any size (a
66-byte payload reproduces it). That throw landed after
"Execution successful" had printed, so the tool ran and the CLI still
exited 1 with nothing on stdout. Responses at or under 10KB no longer
reach encode(), so they now succeed. Larger responses still hit it;
the real fix is passing allowedSpecial 'all' and is not in this commit.
2. TypeScript 6.0.2 (ts-morph's vendored copy) to 6.0.3. Differential
tested: the three real parse helpers over 20 sources covering TSX,
decorators, `using`, `satisfies`, import attributes, optional-chained
calls and unicode gave identical output on all 60 comparisons.
3. Under COMPOSIO_LOG_LEVEL=Debug, ProjectContext's "resolved from ..."
debug lines no longer appear on the remote-execute path. The local-tool
path still calls get() and is unchanged.
Verified with typecheck:src, oxlint, validate:boundaries, the
tools.execute and run command suites (89 tests), and differential tests of
both tokenizers and both TypeScript versions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzaE7oGVgziJ5nRvBhcci2
Correct the getInput JSDoc to pass the required text field, rewrite the
proxyExecute example with the real flat parameter shape and explain that
relative endpoints are appended to a base URL that may include a path, and
show where to find the version to pin for tools.execute.
## Summary
Automated refresh of the toolkit slugs the CLI knows without asking
the API, generated by
`ts/packages/cli/scripts/generate-toolkit-slugs.ts`.
Toolkits added since the last refresh currently cost users one
toolkit-list fetch (~2 s) the first time they run one of that
toolkit's tools. Merging this makes them free.
The generator refuses to write a list that is short, malformed, or
missing staple toolkits, so a bad fetch opens no PR at all.