## Summary
Follow-up to #4310. The TypeScript SDK (since #3800) catches a thrown
backend call for the remote half of a mixed
`COMPOSIO_MULTI_EXECUTE_TOOL` batch and turns it into one failure entry
per remote slug, so completed local results are not lost. The Python SDK
still let the exception escape `_route_multi_execute`, discarding every
local result that had already run.
This ports the TypeScript behavior so both SDKs return the same shape on
a remote transport failure.
Fixes #
## Changes
- Catch the remote future's exception in `_route_multi_execute` and keep
`str(error)`, falling back to `Remote tool execution failed` when the
message is empty (same fallback as TS).
- Synthesize `{response: {successful: False, data: {}, error},
tool_slug, error}` for each remote index and merge them in original
request order.
- Recompute `total_count` / `success_count` / `error_count` on transport
failure, as TS does.
- Add two regression tests mirroring the TS cases in
`customToolRouting.test.ts`: local results preserved with per-tool
remote errors, and the empty-message fallback.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- `uv run --locked --group dev pytest tests/test_custom_tools.py -q`: 87
passed.
- `uv run --locked --group dev nox -s chk`: ruff and mypy clean.
- Without the source change, the new
`test_remote_transport_failure_keeps_local_results` raises
`RuntimeError: remote unavailable` out of `_route_multi_execute`.
## Checklist
- [x] I have read the Code of Conduct and this PR adheres to it
- [x] I ran linters/tests locally and they passed
- [ ] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages
(Python does not use Changesets)
## Additional context
TypeScript reference:
`ts/packages/core/src/models/ToolRouterSession.ts`, the
`remoteErrorMessage` branch, and the test "should preserve successful
local results when remote transport fails".
https://claude.ai/code/session_01PAXMbiZd3qPoJ8Z9uPvEAb
EOF -R ComposioHQ/composio
## Summary
`ToolRouterSession._route_multi_execute` currently concatenates remote
results before local results and assigns new indexes from that
concatenated list. For a request such as `[LOCAL_TOOL, REMOTE_TOOL]`,
callers receive `[REMOTE_TOOL, LOCAL_TOOL]`, so code that correlates
`results[index]` with the requested tools can use the wrong result.
This brings the Python implementation in line with the merged TypeScript
behavior in [#3800](https://github.com/ComposioHQ/composio/pull/3800):
preserve each tool's original request index, restore that order after
local/remote execution, and then assign contiguous result indexes.
Fixes #
## Changes
- Preserve the original index on locally executed result entries.
- Map remote sub-batch results back to their original request indexes
before merging.
- Sort the merged results by original index and re-index them
sequentially.
- Update the mixed local/remote regression test to assert request order
and indexes.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
- `uv run --locked --group dev pytest tests/test_custom_tools.py -q` —
69 passed.
- `uv run --locked --group dev nox -s tst -- tests/test_custom_tools.py`
— 69 passed.
- `uv run --locked --group dev ruff --config config/ruff.toml check
composio/core/models/tool_router_session.py tests/test_custom_tools.py`
— passed.
- Ruff format check on both changed files — passed.
- Verified the regression test fails on the pre-fix implementation and
passes after the fix.
## 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
- [ ] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages
## Additional context
This change is limited to Python multi-execute result ordering.
All-local and all-remote fast paths remain unchanged. No changeset is
needed because this repository does not use Changesets for Python
package changes.
Signed-off-by: CoralGarden52 <2193436736@qq.com>
## Summary
The Python SDK treated a custom tool's `original_slug` as globally
unique, rejecting valid custom toolkits that reuse common child names
such as `SEARCH`, `VERSION`, or `GREP` even though the backend-assigned
final slugs are toolkit-qualified (`LOCAL_ALPHA_GREP`,
`LOCAL_BETA_GREP`).
This ports the toolkit-qualified lookup from #3360 to Python, then fixes
three response-mapping bugs found in review and applies the same fixes
to the TypeScript SDK so both stay in parity.
## Changes
### Python (`composio`)
- Scope custom-tool collision detection and response matching by toolkit
plus original slug.
- Keep bare original-slug aliases only when unambiguous;
`session.execute("GREP")` raises with the final slugs to use when the
slug is shared.
- Preserve toolkit-qualified final slugs in `custom_toolkits()`.
- `build_custom_tools_map_from_response`: raise when a response tool has
local handles but no exact toolkit match instead of silently dropping it
or binding another toolkit's handler; only fall back to a bare match
when the response carries no toolkit identity; reject duplicate
qualified response entries; derive bare-slug ambiguity from local
definitions so omitting a sibling in the response never makes the
survivor callable by bare name.
- `custom_toolkits()` only reuses a bare alias that belongs to the same
toolkit.
- Docstring and Python session reference page state that bare-slug
execution requires a unique original slug.
### TypeScript (`@composio/core`)
- Same four fixes in `buildCustomToolsMapFromResponse` and the same
guard in `customToolkits()`.
- JSDoc and TypeScript session reference page updated.
- Changeset: patch for `@composio/core`.
### Not changed
- `COMPOSIO_MULTI_EXECUTE_TOOL` still aborts the whole batch when one
item uses an ambiguous bare slug, matching current TS behavior.
Switching to per-item errors is a cross-SDK design change left for a
follow-up.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [ ] Breaking change
## How Has This Been Tested?
Python:
- `pytest tests/test_custom_tools.py tests/test_tool_router.py`: 181
passed.
- ruff (project config) clean; mypy reports no errors in the touched
files.
- New tests: sibling routing, multi-execute, preload rejection, listing
guard, and five response-mapping cases (no exact match, cross-toolkit
binding, standalone bare fallback, unknown response tools skipped,
ambiguity from local definitions, duplicate qualified entries).
TypeScript:
- `vitest run` in `ts/packages/core`: 53 files, 1251 passed, 2 expected
failures.
- `tsc --noEmit` clean; prettier and oxlint via pre-commit hook.
- New tests: cross-toolkit reuse in `buildCustomToolsMap` and a new
`buildCustomToolsMapFromResponse` block mirroring the Python cases.
Python and TypeScript CI do not run automatically on this fork PR; a
maintainer needs to approve the workflow run.
## 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
Reviewed with a second opinion from Codex (gpt-5.6-sol), which flagged
the wrong-handler binding and response-derived ambiguity bugs fixed in
the follow-up commits.
https://claude.ai/code/session_01Y7Ni3QEBDGShSrEtwQS5bA
EOF -R ComposioHQ/composio
---------
Signed-off-by: CoralGarden52 <2193436736@qq.com>
Co-authored-by: jkomyno <alberto@composio.dev>
Co-authored-by: Alberto Schiabel <jkomyno@users.noreply.github.com>
> ### ⚠️ Breaking change
>
> `proxy_execute()` now returns a dict instead of the generated
`SessionProxyExecuteResponse` model. Every caller since `py@0.11.4` that
reads the result with attribute access breaks at runtime with
`AttributeError`.
>
> ```python
> # before
> response.status
>
> # after
> response["status"]
> ```
>
> `data`, `headers`, and `binary_data` follow the same rule. No version
bump or changelog entry ships in this PR. That omission is deliberate,
so the release call stays explicit. Details below.
## Summary
Builds on @AseemPrasad's #4163, which spotted a real problem. Python's
`proxy_execute()` returns the generated client's
`SessionProxyExecuteResponse` directly, while TypeScript's
`proxyExecute()` projects onto a curated shape. Returning the generated
model leaks a regenerated artifact into a public SDK return type.
This PR keeps that fix and resolves the review findings on top. #4163's
commit is preserved with its original authorship. The commits on top
carry the correction and the review fixes.
## What changed relative to #4163
| | #4163 | Here |
|---|---|---|
| Key casing | `binaryData`, `contentType`, `expiresAt` | `binary_data`,
`content_type`, `expires_at` |
| `status` type | declared `int`, returned `200.0` | declared `int`,
returns `200` |
| Test doubles | `SimpleNamespace` | real `SessionProxyExecuteResponse`
/ `BinaryData` |
| `mypy` | fails `nox -s chk` | clean |
| Docs | 3 snippets left broken | fixed |
**Casing.** Python public APIs use snake_case and TypeScript public APIs
use camelCase. The fields and their meanings match across SDKs, and the
spelling follows each language. `session.delete()` already works this
way (`session_id` in Python, `sessionId` in TypeScript), and so does
`RemoteFile` (`expires_at` / `expiresAt`).
**`status` and `size` are narrowed to `int`.** The generated model types
both as `float` and pydantic coerces, so a response read straight off it
renders `200.0` where TypeScript renders `200`. #4163 declared `int` but
still returned `200.0`. That mismatch also failed `nox -s chk`:
```
composio/core/models/session_context.py:56: error: Incompatible types
(expression has type "float", TypedDict item "status" has type "int") [typeddict-item]
```
**Tests use the real generated models again.** `SimpleNamespace` accepts
any attribute name and any type, so it silently tolerates a client
regeneration that renames or retypes a field. It was also what hid the
`float` coercion, since `assert result == {"status": 200}` passes
against `200.0`. The suite now asserts the narrowed types directly. This
matters ahead of the `composio-client` 2.x migration, which types every
response field as `Any` and removes type checking on this projection
entirely. The tests become the only remaining check.
**Simplification.** The projection folds into `proxy_execute_impl`, so
both entry points are a single call rather than an impl-then-normalize
pair. `response.binary_data` is read directly instead of through
`getattr(..., None)`. The defensive default could never fire on a typed
response, but it made mypy infer `Any` and stop checking the projection.
**Docs.** Three Python snippets that read the result as attributes are
fixed, and the response-shape table gets a per-language column. The
follow-up commit also marks `headers` and `data` as nullable in that
table, replaces the "returns the upstream response verbatim" claim with
what the projection actually does, and documents that `expires_at` can
be absent in TypeScript and `None` in Python.
## Breaking change
The method has shipped since `py@0.11.4`. Both directions of the old
access pattern were already inconsistent in the repo.
`python/examples/custom_tools_agent_test.py:95` does `res["status"]`,
which raises `TypeError` on `next` today and is fixed by this PR. The
doc snippets did attribute access and are updated here.
No changelog entry and no version bump are included. That is deliberate,
so the release call stays explicit rather than implied by the merge.
## How Has This Been Tested?
```bash
cd python
mypy --config-file config/mypy.ini composio/ tests/ # clean
ruff check --config config/ruff.toml composio/ tests/ # clean
pytest tests/ # 1336 passed, 33 skipped
```
`ruff format` was run with the repo's pinned toolchain.
## Type of change
- [x] Bug fix
- [ ] New feature
- [ ] Refactor/Chore
- [ ] Documentation
- [x] Breaking change
## Checklist
- [x] I ran linters/tests locally and they passed
- [x] I updated documentation as needed
- [x] I added tests or explain why not applicable
- [ ] I added a changeset if this change affects published packages. Not
applicable: `AGENTS.md` reserves changesets for published TypeScript
packages
https://claude.ai/code/session_01GsD8zvAhrjFwk144oWkD9K
---------
Co-authored-by: AseemPrasad <aseemprasad0520@gmail.com>
Co-authored-by: Kshitij Jhunjhunwala <113939507+KJ-11@users.noreply.github.com>
ExperimentalAPI was sitting inside custom_tool.py because custom tools
were the first thing the SDK exposed on the composio.experimental
namespace. Now that update_acl has moved onto the same namespace,
keeping the class in custom_tool.py reads wrong — grepping for
update_acl lands in a file named after custom tools.
Splits the class out into core/models/experimental.py. custom_tool.py
keeps the custom-tool machinery (ExperimentalToolkit, decorator
helpers, serializers); experimental.py imports what it needs from
custom_tool.py and is now the home for anything on the
composio.experimental namespace.
Pure rearrangement — no behaviour change, no public-import-path change
(composio.core.models still re-exports ExperimentalAPI). Stack-frame
depth in _get_caller_locals(depth=2) stays correct: the
decorator → user-module hop is the same regardless of which file
ExperimentalAPI.tool lives in.
Tests still pass (201/201), mypy still clean on composio/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
Automatic tool file upload/download for `file_uploadable` fields is
**off by default** in TypeScript and Python. Callers must explicitly opt
in, and uploads from local paths are constrained by a fail-closed
allowlist.
## Changes
- **Removed (breaking):** `autoUploadDownloadFiles` (TS) /
`auto_upload_download_files` (Python) — the legacy default-on flag is
gone, not just deprecated.
- **New opt-in:** `dangerouslyAllowAutoUploadDownloadFiles` (TS) /
`dangerously_allow_auto_upload_download_files` (Python). When `true`,
`tools.get(...)` collapses `file_uploadable` schemas to `{ type:
'string', format: 'path' }` and the SDK stages local paths/URLs at
execute time.
- **New:** `fileUploadDirs?: string[] | false` — fail-closed allowlist
for local upload paths. `undefined` → `[<home>/.composio/temp]`; `false`
→ reject all local paths (URLs / `File` objects unaffected); explicit
`string[]` replaces the default. Components are matched on a path
boundary after `realpath`.
- **New:** `fileDownloadDir?: string` — directory where
`file_downloadable` results are staged.
- **New:** `beforeFileUpload` hook receives `source: 'path' | 'url' |
'file'` (TS) / `'path' | 'url'` (Python) so it can branch on input type.
- **New (TS):** when auto-upload is **off** and an LLM-driven
`tools.execute` is called against a tool with `file_uploadable` inputs,
the SDK emits a one-shot warning per tool slug pointing at
`composio.files.upload()` for manual staging.
## Migration
To restore previous behavior:
```ts
new Composio({
apiKey: process.env.COMPOSIO_API_KEY!,
dangerouslyAllowAutoUploadDownloadFiles: true,
// Optional: tighten the allowlist beyond the default ~/.composio/temp
fileUploadDirs: ['/srv/uploads'],
});
```
```python
Composio(api_key="...", dangerously_allow_auto_upload_download_files=True)
```
If you previously passed the legacy flag, remove it. There is no
transitional warning — TS and Python both reject the unknown property at
the type/keyword-arg level.
## Versioning
| Package | Bump |
| ------- | ---- |
| `@composio/core` | minor |
| `composio` (Python) | minor |
| Other `@composio/*` packages | patch (via changesets
`updateInternalDependencies: "patch"`) |
See
`docs/content/changelog/04-24-26-legacy-auto-upload-config-removal.mdx`
for the full migration writeup.
Both session.execute() and ctx.execute() now return the same
SessionExecuteResponse type. No more ToolExecutionResponse dict
for ctx — consistent API whether called from user code or from
within a custom tool.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- session.execute() returns SessionExecuteResponse for both local and remote
- proxy_execute() returns SessionProxyExecuteResponse directly from client
(no more manual dict construction)
- Remove dead ProxyExecuteResponse/ProxyExecuteBinaryData TypedDicts
- SessionContext protocol uses generated types
- No t.Any return types on new methods
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both local and remote session.execute() now return
SessionExecuteResponse — consistent type, supports attribute
access (result.data, result.error, result.log_id) everywhere.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remote path returns the SessionExecuteResponse client model as-is
(supports attribute access like result.data, result.log_id).
Only local custom tool path returns a dict.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix async validation bypass for single-param tools: check
asyncio.iscoroutinefunction(fn) before wrapping in _infer_tool
- Remove inline # slug/name comments from example (jkomyno review)
- Add test for async single-param rejection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Multi-line docstrings now have indentation properly stripped via
inspect.cleandoc() instead of bare .strip(). Added tests for
multi-line and indented docstrings.
Slug inference: fn.__name__.upper() (e.g. search_users -> SEARCH_USERS)
Name inference: fn.__name__ humanized (e.g. search_users -> Search Users)
Description inference: inspect.cleandoc(fn.__doc__)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove unnecessary forward-ref quotes (from __future__ annotations
already handles it)
- Match TS multi-execute merge order: remotes first, locals appended
(remote results may have workbench index references)
- Simplify error logic to match TS: simple "X out of Y failed" message
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Strict function signature validation in @tool() decorator: enforce
(input: BaseModel) or (input: BaseModel, ctx) shape, reject swapped
params, >2 params, and missing BaseModel annotation on first param
2. Consistent session.execute() return type: both local and remote paths
now return a dict with data/error/log_id keys (no more model vs dict
divergence)
3. Client-side validation in proxy_execute: validate toolkit, endpoint,
method, and parameter shapes before hitting the API (matches TS
SessionProxyExecuteParamsSchema behavior)
4. Document intentional order divergence in multi-execute merge: Python
preserves original request order (index-based), TS appends locals
after remotes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_route_multi_execute no longer accepts or passes modifiers to the
remote execution path. The outer routing_execute already applies
before_execute/after_execute modifiers, so passing them into the
inner _wrap_execute_tool_for_tool_router would cause double application.
Addresses Cursor Bugbot High severity finding.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reject async execute handlers at creation time (SDK is sync-only)
- Preserve remote batch metadata when merging local/remote multi-execute
- Normalize session.execute() to always return plain dict (both local
and remote paths now return {data, error, log_id})
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix 6 ruff lint errors: remove unused imports and variable in tests
- Fix case-insensitive lookup: uppercase final_slug keys in
build_custom_tools_map_from_response to match find_custom_tool's .upper()
- Extract proxy_execute into shared helper (proxy_execute_impl) to
eliminate duplicated logic between SessionContextImpl and ToolRouterSession
- Remove leftover dead code (return result)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- P1: Propagate failure status in multi-execute — merged results now
report successful=False and error count when any sub-tool fails
- P2: Normalize session.execute() return shape for local tools to match
SessionExecuteResponse (data/error/log_id) instead of ToolExecutionResponse
- P2: Detect duplicate original slugs in build_custom_tools_map_from_response
instead of silently overwriting
- P3: Reject RootModel input_params — tool router only passes named argument
objects, so non-object schemas are rejected at creation time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port the TypeScript custom tools feature to the Python SDK, enabling
developers to define local tools that run in-process alongside remote
Composio tools within a session.
Three tool patterns supported:
- Standalone tools (no auth)
- Extension tools (inherits auth from a Composio toolkit via extends_toolkit)
- Custom toolkits (groups related tools under one namespace)
Key implementation:
- Factory functions: experimental_create_tool() and experimental_create_toolkit()
- Pydantic BaseModel for input schema (equivalent of Zod in TS)
- SessionContext with execute() for sibling routing and proxy_execute() for auth proxy
- COMPOSIO_MULTI_EXECUTE_TOOL routing: splits local/remote, parallel execution via ThreadPoolExecutor (max 5 workers)
- Custom tools map built from backend response (authoritative slug mapping)
- Bumps composio-client to 1.29.0 for custom_tools/custom_toolkits/proxy_execute types
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>