Skip to content

perf(cli): list connected accounts once per execute - #4475

Draft
Daksh (sudodaksh) wants to merge 2 commits into
claude/cli-startup-bundle-diet-c7xiczfrom
claude/cli-execute-dedupe-requests-c7xicz
Draft

perf(cli): list connected accounts once per execute#4475
Daksh (sudodaksh) wants to merge 2 commits into
claude/cli-startup-bundle-diet-c7xiczfrom
claude/cli-execute-dedupe-requests-c7xicz

Conversation

@sudodaksh

@sudodaksh Daksh (sudodaksh) commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

composio execute made eight backend requests. Two of them were the same list of the user's connected accounts, fetched by two code paths that cannot see each other. It is fetched once now. Interleaved A/B against #4469, compiled binaries, 15 runs each on a small response: 1735ms to 1636ms best, 1934ms to 1845ms median. That is one round trip (~140ms) off the critical path.

Results are identical to before in every case. The picker derives its toolkit subset from the shared list with the exact semantics of its old query, and falls back to that query when the shared list is truncated.

Fifth PR in the stack. Stacked on #4469; review #4463, #4464, #4468 and #4469 first.

Changes

  1. src/utils/memoize-in-process.ts (new). Memoizes an Effect per key for the process lifetime, shares one run between concurrent callers, and drops a failure, defect or interruption so the next caller retries.
  2. listActiveConnectedAccounts in connected-account-selection.ts: the unfiltered GET /connected_accounts for a user, memoized by client identity and user id. It fails with the raw rejection, and each caller wraps that in its own error. resolveToolRouterSessionConnections reads from it when it has no toolkit filter, which is the execute path. With a filter it keeps its own request.
  3. resolveConnectedAccountForToolkit used to issue its own request, toolkit-filtered, limit: 100. It now derives that from the shared list: same slug match, server order preserved, first 100. If the shared list has a next_cursor or total_items above what it holds, the toolkit's accounts may sit past the cut, so the original filtered request runs instead.
  4. get_latest_version goes through the same memo. The definition refresh fetched it twice with identical headers on the stale path; that is one request now. The executor's own version lookup sends no org or project headers and stays a separate request. Scoping it would change which definition it resolves under, which is a semantics decision, not a perf one.

What does not change: the request list on a normal execute is now project/resolve, connected_accounts, get_latest_version twice, consumer/config, session, execute. Error messages are unchanged; the fallback passes the raw rejection through so the picker's message reads as before.

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
  2. pnpm exec vitest run: 131 files, 1341 passed, 1 skipped (whole stack). New tests cover the memo sharing one run per key and retrying after a failure or a defect. The execute suite already covers account selection with and without a selector and passes unchanged. The test layer builds a fresh client per test, so the client-keyed memo does not bleed between tests.
  3. Request count: hooked fetch while running execute HACKERNEWS_GET_ITEM_WITH_ID from source. connected_accounts appears once, the rest of the list as before.
  4. Timing: pnpm build:binary, then the interleaved A/B above against perf(cli): move the compiler and tokenizer out of the executable #4469's binary. A second round of 12 gave 1788 to 1621ms best, 2045 to 1963ms median.

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

No docs describe the request sequence. @composio/cli is private, so no changeset.

Additional context

The rest of an execute, from the same trace: project/resolve 140 to 640ms with no cache, tool_router/session 385 to 655ms created per invocation, and the execute call itself 500 to 730ms. The connected-account cache in consumer-short-term-cache.ts would take connected_accounts off the path entirely, but DISABLE_CONNECTED_ACCOUNT_CACHE defaults to on, and enabling it fails no-auth toolkits with "not connected" because the cached list does not include them. Both are separate changes.

🤖 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/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install.sh" | sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/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/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install/bash.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install.sh" sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install/zsh.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install.sh" sh
curl -fsSL "https://raw.githubusercontent.com/ComposioHQ/composio/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install/fish.sh" | COMPOSIO_INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/ComposioHQ/composio/0a572b0421c5a3cc8e8b4b670ccbb3cb7db10822/install.sh" sh

@sudodaksh

Copy link
Copy Markdown
Collaborator Author

Not in this PR, but worth a look: get_latest_version is still requested twice per execute, ~35ms serial.

The two requests differ by two headers. The command passes the resolved org and project (tools.execute.cmd.ts:837 and :1103), so getLatestToolVersion sends x-org-id and x-project-id. The executor calls getOrFetchToolInputDefinition(slug) with no scope (tools-executor.ts:204), so it sends neither. Both write the same cache file under ~/.composio/tool_definitions/.

That looks like a bug rather than a design choice. Arguments are validated against the scoped definition, then file uploads are normalized against the unscoped one. If the backend ever answers per project (version overrides, custom tools), the two lookups disagree, each overwrites the other's cache entry, and every run downloads the definition again.

I probed the endpoint with and without the org header for HACKERNEWS_GET_ITEM_WITH_ID, GMAIL_SEND_EMAIL and GITHUB_CREATE_AN_ISSUE: same version each time. So today the second request is a pure duplicate. A project with an override or a custom tool is the case I could not check, and the one where the answer might differ.

The fix is small: pass the command's { orgId, projectId } through ToolExecuteParams into the executor's lookup. Both requests then share one key in memoizeInProcess and the second one disappears. Left out here because it changes which definition the executor resolves under, and that call is yours.

@sudodaksh
Daksh (sudodaksh) added this pull request to stack #4465 September 13, 2026 13:01

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and simplified this locally. The request savings stay the same: one GET /connected_accounts and one shared get_latest_version per execute. With the suggestions below applied on top of this head: pnpm run typecheck, validate:boundaries, and oxlint on the changed files pass, and vitest run passes (130 files, 1338 passed, 1 skipped).

Checked and sound

  • total_items is a required number in @composio/client, so the complete-list check only falls back to the filtered request when the shared list is actually truncated.
  • tools.execute.cmd.ts passes the same client instance into the account picker and the executor, so both memo keys match and the request is shared.
  • Process-lifetime memoization can't go stale inside composio run: execute() in run helpers spawns a separate composio execute process per tool.

Suggestions (inline)

  1. memoizeInProcess evicts only on typed failures (tapError). Effect.cached stores the whole Exit, so a defect or an interruption of the first caller stays cached and is replayed to every later caller for the rest of the process. Effect.onError covers every failure cause. A regression test for the defect case is included.
  2. ActiveConnectedAccountsListError is built but only its .cause is ever read; both callers unwrap it and rewrap in their own error. Failing with the raw rejection (as fetchConnectedAccountsForToolkit already does) removes the wrap/unwrap round trip. Error messages are unchanged.
  3. Doc comment wording: describe the current behavior rather than what the code used to do.

Not changed

  • Cache from effect looks like a replacement for memoizeInProcess, but its lookup only receives the key, so the client object can't reach the request through a string key. The small helper is justified.
  • tools-executor.ts still calls getOrFetchToolInputDefinition(slug) without org/project, so its get_latest_version gets a different memo key. The description already calls that out as a semantics decision.

Comment on lines +5 to +6
* between concurrent callers. A success stays cached; a failure is dropped so
* the next caller retries instead of replaying the error.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect.cached memoizes the full Exit, so defects and interruptions need evicting too (see next comment).

Suggested change
* between concurrent callers. A success stays cached; a failure is dropped so
* the next caller retries instead of replaying the error.
* between concurrent callers. A success stays cached; a failure, defect, or
* interruption is dropped so the next caller retries instead of replaying it.

// another fiber.
const cached = Effect.runSync(
Effect.cached(
options.make(input).pipe(Effect.tapError(() => Effect.sync(() => cache.delete(key))))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tapError only runs on typed failures. If make dies, or the first caller is interrupted mid-request, Effect.cached keeps that exit and every later caller in the process gets it replayed. onError runs for any failure cause.

Suggested change
options.make(input).pipe(Effect.tapError(() => Effect.sync(() => cache.delete(key))))
options.make(input).pipe(Effect.onError(() => Effect.sync(() => cache.delete(key))))

expect(yield* load('k')).toBe(2);
expect(calls).toBe(2);
})
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regression test for the eviction change above (fails with tapError, passes with onError).

Suggested change
);
);
it.effect('drops a defect so the next caller retries', () =>
Effect.gen(function* () {
let calls = 0;
const load = memoizeInProcess({
keyOf: (key: string) => key,
make: () =>
Effect.suspend(() =>
++calls === 1 ? Effect.die('first call dies') : Effect.succeed(calls)
),
});
const first = yield* Effect.exit(load('k'));
expect(Exit.isFailure(first)).toBe(true);
expect(yield* load('k')).toBe(2);
expect(calls).toBe(2);
})
);

Comment on lines +141 to +147
export class ActiveConnectedAccountsListError extends Data.TaggedError(
'services/ActiveConnectedAccountsListError'
)<{
readonly message: string;
readonly cause: unknown;
}> {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only .cause of this error is ever read: listConnectedAccountsForToolkit unwraps it, and resolveToolRouterSessionConnections rewraps error.cause in its own error. Suggest dropping it and failing with the raw rejection, like fetchConnectedAccountsForToolkit already does.

Suggested change
export class ActiveConnectedAccountsListError extends Data.TaggedError(
'services/ActiveConnectedAccountsListError'
)<{
readonly message: string;
readonly cause: unknown;
}> {}

Comment on lines +169 to +174
* builds the session's connection context from the full list. Both used to
* issue their own `GET /connected_accounts`, one toolkit-filtered and one not.
* The per-toolkit view is a subset of this list, so both read from here.
*
* `limit: 1000` is the session path's existing page size; a user with more
* active accounts than that was already truncated there.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* builds the session's connection context from the full list. Both used to
* issue their own `GET /connected_accounts`, one toolkit-filtered and one not.
* The per-toolkit view is a subset of this list, so both read from here.
*
* `limit: 1000` is the session path's existing page size; a user with more
* active accounts than that was already truncated there.
* builds the session's connection context from the full list. The per-toolkit
* view is a subset of this list, so both read from here instead of issuing
* their own `GET /connected_accounts`.
*
* `limit: 1000` is the session path's existing page size; a user with more
* active accounts than that was already truncated there. Fails with the raw
* rejection; each caller wraps it in its own error.

Comment on lines +187 to +191
catch: cause =>
new ActiveConnectedAccountsListError({
message: `Failed to list connected accounts for user "${userId}".`,
cause,
}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
catch: cause =>
new ActiveConnectedAccountsListError({
message: `Failed to list connected accounts for user "${userId}".`,
cause,
}),
catch: cause => cause,

const shared = yield* listActiveConnectedAccounts({
client: params.client,
userId: params.userId,
}).pipe(Effect.mapError(error => error.cause));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}).pipe(Effect.mapError(error => error.cause));
});

Comment on lines +95 to +98
error =>
new ToolRouterSessionConnectionsError({
message: `Failed to list connected accounts for user "${userId}".`,
cause: error.cause,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
error =>
new ToolRouterSessionConnectionsError({
message: `Failed to list connected accounts for user "${userId}".`,
cause: error.cause,
cause =>
new ToolRouterSessionConnectionsError({
message: `Failed to list connected accounts for user "${userId}".`,
cause,

DakshM on Exe (exe.dev) and others added 2 commits September 14, 2026 14:07
`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
…error

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.
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