2026-03-20 12:48:03 -07:00
|
|
|
"""Tests for custom tools in tool router sessions.
|
|
|
|
|
|
|
|
|
|
Covers:
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
- Decorator API (inference, overrides, validation)
|
|
|
|
|
- Toolkit builder with @toolkit.tool() decorator
|
2026-03-20 12:48:03 -07:00
|
|
|
- Slug prefixing and collision detection
|
|
|
|
|
- Serialization to API format
|
|
|
|
|
- Custom tool execution (validation, defaults, errors)
|
|
|
|
|
- Routing map building and lookup
|
|
|
|
|
- SessionContextImpl sibling routing
|
|
|
|
|
- ToolRouterSession integration (execute routing, custom_tools(), custom_toolkits())
|
|
|
|
|
- Multi-execute routing (all-local, all-remote, mixed, parallel)
|
|
|
|
|
"""
|
|
|
|
|
|
2026-05-06 15:51:28 +05:30
|
|
|
from dataclasses import replace
|
2026-03-20 13:36:13 -07:00
|
|
|
from unittest.mock import MagicMock
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
import pytest
|
2026-05-06 18:44:40 +05:30
|
|
|
from composio_client import omit
|
2026-03-24 14:59:04 -07:00
|
|
|
from composio_client.types.tool_router.session_execute_response import (
|
|
|
|
|
SessionExecuteResponse,
|
|
|
|
|
)
|
|
|
|
|
from composio_client.types.tool_router.session_proxy_execute_response import (
|
fix(python): own the proxy_execute response shape (#4180)
> ### ⚠️ 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>
2026-08-23 00:30:58 +02:00
|
|
|
BinaryData,
|
2026-03-24 14:59:04 -07:00
|
|
|
SessionProxyExecuteResponse,
|
|
|
|
|
)
|
2026-03-20 12:48:03 -07:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
from composio.core.models.custom_tool import (
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
ExperimentalToolkit,
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
assert_no_custom_tool_slugs_in_preload,
|
2026-03-20 12:48:03 -07:00
|
|
|
build_custom_tools_map,
|
|
|
|
|
build_custom_tools_map_from_response,
|
|
|
|
|
serialize_custom_toolkits,
|
2026-05-12 20:53:01 +05:30
|
|
|
serialize_custom_tools,
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
|
|
|
|
from composio.core.models.custom_tool_execution import (
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
assert_unambiguous_custom_tool_slug,
|
2026-03-20 12:48:03 -07:00
|
|
|
execute_custom_tool,
|
|
|
|
|
find_custom_tool,
|
|
|
|
|
)
|
2026-05-12 20:53:01 +05:30
|
|
|
from composio.core.models.experimental import ExperimentalAPI
|
2026-03-20 12:48:03 -07:00
|
|
|
from composio.core.models.session_context import SessionContextImpl
|
|
|
|
|
from composio.core.models.tool_router_session import ToolRouterSession
|
|
|
|
|
from composio.exceptions import ValidationError
|
|
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
# Fixtures
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
exp = ExperimentalAPI()
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
class GrepInput(BaseModel):
|
|
|
|
|
pattern: str = Field(description="Pattern to search for")
|
|
|
|
|
path: str = Field(default=".", description="File path")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmailInput(BaseModel):
|
|
|
|
|
to: str = Field(description="Recipient email")
|
|
|
|
|
subject: str = Field(description="Subject")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetRoleInput(BaseModel):
|
|
|
|
|
user_id: str = Field(description="User ID")
|
|
|
|
|
role: str = Field(default="viewer", description="New role")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def grep_tool():
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
@exp.tool()
|
|
|
|
|
def grep(input: GrepInput, ctx):
|
|
|
|
|
"""Search for patterns in files."""
|
|
|
|
|
return {"matches": [input.pattern], "path": input.path}
|
|
|
|
|
|
|
|
|
|
return grep
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
@pytest.fixture
|
|
|
|
|
def duplicate_slug_tools():
|
|
|
|
|
@exp.tool(slug="GREP")
|
|
|
|
|
def alpha_grep(input: GrepInput, ctx):
|
|
|
|
|
"""Search Alpha."""
|
|
|
|
|
return {"toolkit": "alpha", "matches": [input.pattern]}
|
|
|
|
|
|
|
|
|
|
@exp.tool(slug="GREP")
|
|
|
|
|
def beta_grep(input: GrepInput, ctx):
|
|
|
|
|
"""Search Beta."""
|
|
|
|
|
return {"toolkit": "beta", "matches": [input.pattern]}
|
|
|
|
|
|
|
|
|
|
return alpha_grep, beta_grep
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
@pytest.fixture
|
|
|
|
|
def email_tool():
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
@exp.tool(extends_toolkit="gmail")
|
|
|
|
|
def get_emails(input: EmailInput, ctx):
|
|
|
|
|
"""Get emails from Gmail."""
|
|
|
|
|
return {"emails": []}
|
|
|
|
|
|
|
|
|
|
return get_emails
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def set_role_tool():
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
@exp.tool()
|
|
|
|
|
def set_role(input: SetRoleInput, ctx):
|
|
|
|
|
"""Set a user's role."""
|
|
|
|
|
return {"user_id": input.user_id, "role": input.role, "updated": True}
|
|
|
|
|
|
|
|
|
|
return set_role
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def role_toolkit(set_role_tool):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
tk = ExperimentalToolkit(
|
|
|
|
|
slug="ROLE_MANAGER", name="Role Manager", description="Manage user roles"
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
tk._tools.append(set_role_tool)
|
|
|
|
|
return tk
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class MockSessionContext:
|
|
|
|
|
user_id = "test-user"
|
|
|
|
|
|
|
|
|
|
def execute(self, tool_slug, arguments):
|
|
|
|
|
return {"data": {}, "error": None, "successful": True}
|
|
|
|
|
|
|
|
|
|
def proxy_execute(self, **kwargs):
|
|
|
|
|
return {"status": 200, "data": {}, "headers": {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
# Decorator API tests
|
2026-03-20 12:48:03 -07:00
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
class TestDecoratorTool:
|
|
|
|
|
def test_bare_decorator(self):
|
|
|
|
|
@exp.tool
|
|
|
|
|
def my_tool(input: GrepInput, ctx):
|
|
|
|
|
"""My tool description."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert my_tool.slug == "MY_TOOL"
|
|
|
|
|
assert my_tool.name == "My Tool"
|
|
|
|
|
assert my_tool.description == "My tool description."
|
|
|
|
|
assert my_tool.input_schema["type"] == "object"
|
|
|
|
|
assert "pattern" in my_tool.input_schema["properties"]
|
|
|
|
|
|
|
|
|
|
def test_decorator_with_parens(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def search(input: GrepInput, ctx):
|
|
|
|
|
"""Search stuff."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert search.slug == "SEARCH"
|
|
|
|
|
|
|
|
|
|
def test_decorator_with_extends_toolkit(self):
|
|
|
|
|
@exp.tool(extends_toolkit="gmail")
|
|
|
|
|
def fetch_mail(input: EmailInput, ctx):
|
|
|
|
|
"""Fetch mail."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert fetch_mail.extends_toolkit == "gmail"
|
|
|
|
|
|
2026-05-06 15:51:28 +05:30
|
|
|
def test_decorator_with_preload(self):
|
|
|
|
|
@exp.tool(preload=True)
|
|
|
|
|
def fetch_context(input: GrepInput, ctx):
|
|
|
|
|
"""Fetch context."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert fetch_context.preload is True
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_decorator_explicit_overrides(self):
|
|
|
|
|
@exp.tool(slug="CUSTOM", name="Custom Name", description="Custom desc")
|
|
|
|
|
def whatever(input: GrepInput, ctx):
|
|
|
|
|
"""Original docstring."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert whatever.slug == "CUSTOM"
|
|
|
|
|
assert whatever.name == "Custom Name"
|
|
|
|
|
assert whatever.description == "Custom desc"
|
|
|
|
|
|
|
|
|
|
def test_infers_from_function_name(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def search_user_by_email(input: GrepInput, ctx):
|
|
|
|
|
"""Find a user."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert search_user_by_email.slug == "SEARCH_USER_BY_EMAIL"
|
|
|
|
|
assert search_user_by_email.name == "Search User By Email"
|
|
|
|
|
|
|
|
|
|
def test_infers_description_from_docstring(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def my_func(input: GrepInput, ctx):
|
|
|
|
|
"""Trimmed docstring."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert my_func.description == "Trimmed docstring."
|
|
|
|
|
|
2026-03-23 17:52:01 -07:00
|
|
|
def test_multiline_docstring(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def multi(input: GrepInput, ctx):
|
|
|
|
|
"""Search for users by email.
|
|
|
|
|
|
|
|
|
|
This tool searches the internal database for users
|
|
|
|
|
matching the given pattern. Returns a list of matches.
|
|
|
|
|
|
|
|
|
|
Supports wildcards.
|
|
|
|
|
"""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert multi.description.startswith("Search for users by email.")
|
|
|
|
|
# inspect.cleandoc strips indentation
|
|
|
|
|
assert "\n " not in multi.description
|
|
|
|
|
assert "Supports wildcards." in multi.description
|
|
|
|
|
|
|
|
|
|
def test_indented_docstring_cleaned(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def indented(input: GrepInput, ctx):
|
|
|
|
|
"""
|
|
|
|
|
Leading and trailing whitespace removed.
|
|
|
|
|
Indentation normalized.
|
|
|
|
|
"""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert indented.description == (
|
|
|
|
|
"Leading and trailing whitespace removed.\nIndentation normalized."
|
|
|
|
|
)
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_missing_docstring_raises(self):
|
2026-03-20 12:48:03 -07:00
|
|
|
with pytest.raises(ValidationError, match="description is required"):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def no_doc(input: GrepInput, ctx):
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def test_missing_basemodel_annotation_raises(self):
|
2026-03-20 12:48:03 -07:00
|
|
|
with pytest.raises(ValidationError, match="BaseModel subclass"):
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
@exp.tool()
|
|
|
|
|
def bad(x: str, ctx):
|
|
|
|
|
"""Bad tool."""
|
|
|
|
|
return {}
|
|
|
|
|
|
2026-03-23 17:15:53 -07:00
|
|
|
def test_no_params_rejected(self):
|
|
|
|
|
with pytest.raises(ValidationError, match="at least one parameter"):
|
|
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def no_params():
|
|
|
|
|
"""No params."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def test_too_many_params_rejected(self):
|
|
|
|
|
with pytest.raises(ValidationError, match="at most 2"):
|
|
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def three_params(input: GrepInput, ctx, extra):
|
|
|
|
|
"""Three params."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def test_first_param_must_be_basemodel(self):
|
|
|
|
|
with pytest.raises(ValidationError, match="first parameter"):
|
|
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def swapped(ctx, input: GrepInput):
|
|
|
|
|
"""Swapped order — ctx first, input second."""
|
|
|
|
|
return {}
|
|
|
|
|
|
2026-03-24 15:29:24 -07:00
|
|
|
def test_future_annotations_are_resolved(self):
|
|
|
|
|
namespace = {"exp": exp}
|
|
|
|
|
exec(
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FutureInput(BaseModel):
|
|
|
|
|
pattern: str = Field(description="Pattern to search for")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def future_tool(input: FutureInput, ctx):
|
|
|
|
|
'''Resolved from postponed annotations.'''
|
|
|
|
|
return {}
|
|
|
|
|
""",
|
|
|
|
|
namespace,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
future_tool = namespace["future_tool"]
|
|
|
|
|
future_input = namespace["FutureInput"]
|
|
|
|
|
|
|
|
|
|
assert future_tool.input_params is future_input
|
|
|
|
|
|
|
|
|
|
def test_local_string_annotations_are_resolved(self):
|
|
|
|
|
def build_tool():
|
|
|
|
|
class LocalInput(BaseModel):
|
|
|
|
|
pattern: str = Field(description="Pattern to search for")
|
|
|
|
|
|
|
|
|
|
@exp.tool
|
|
|
|
|
def nested_search(input: "LocalInput", ctx):
|
|
|
|
|
"""Resolved from local string annotations."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
return nested_search, LocalInput
|
|
|
|
|
|
|
|
|
|
local_tool, local_input = build_tool()
|
|
|
|
|
assert local_tool.input_params is local_input
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_async_function_rejected(self):
|
2026-03-24 14:03:00 -07:00
|
|
|
with pytest.raises(ValidationError, match="async"):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
async def async_tool(input: GrepInput, ctx):
|
|
|
|
|
"""Async tool."""
|
|
|
|
|
return {}
|
|
|
|
|
|
2026-03-24 14:03:00 -07:00
|
|
|
def test_async_single_param_rejected(self):
|
|
|
|
|
"""Async must be caught even when wrapper hides it (single-param path)."""
|
|
|
|
|
with pytest.raises(ValidationError, match="async"):
|
|
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
async def async_single(input: GrepInput):
|
|
|
|
|
"""Async single param."""
|
|
|
|
|
return {}
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_root_model_rejected(self):
|
2026-03-20 13:19:58 -07:00
|
|
|
from pydantic import RootModel
|
|
|
|
|
|
|
|
|
|
class ListInput(RootModel[list[int]]):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="RootModel"):
|
2026-03-20 13:38:32 -07:00
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
@exp.tool()
|
|
|
|
|
def bad(input: ListInput, ctx):
|
|
|
|
|
"""Bad tool."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def test_single_param_function(self):
|
|
|
|
|
"""Function with only input param (no ctx) should work."""
|
|
|
|
|
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def simple(input: GrepInput):
|
|
|
|
|
"""Simple tool."""
|
|
|
|
|
return {"pattern": input.pattern}
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map([simple])
|
|
|
|
|
entry = find_custom_tool(m, "SIMPLE")
|
|
|
|
|
result = execute_custom_tool(entry, {"pattern": "test"}, MockSessionContext()) # type: ignore
|
|
|
|
|
assert result["successful"] is True
|
|
|
|
|
assert result["data"]["pattern"] == "test"
|
|
|
|
|
|
|
|
|
|
def test_slug_validation(self):
|
|
|
|
|
with pytest.raises(ValidationError, match="LOCAL_"):
|
|
|
|
|
|
|
|
|
|
@exp.tool(slug="LOCAL_BAD")
|
|
|
|
|
def bad(input: GrepInput, ctx):
|
|
|
|
|
"""Bad tool."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def test_creates_frozen_custom_tool(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def frozen(input: GrepInput, ctx):
|
|
|
|
|
"""Frozen tool."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
with pytest.raises(AttributeError):
|
|
|
|
|
frozen.slug = "NEW" # type: ignore
|
2026-03-20 13:38:32 -07:00
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
def test_input_schema_includes_defaults(self, grep_tool):
|
|
|
|
|
assert "pattern" in grep_tool.input_schema.get("required", [])
|
|
|
|
|
assert "path" not in grep_tool.input_schema.get("required", [])
|
|
|
|
|
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
class TestToolkitBuilder:
|
|
|
|
|
def test_toolkit_with_decorator(self):
|
|
|
|
|
tk = ExperimentalToolkit(slug="MY_TK", name="My TK", description="Desc")
|
|
|
|
|
|
|
|
|
|
@tk.tool()
|
|
|
|
|
def tool_a(input: GrepInput, ctx):
|
|
|
|
|
"""Tool A."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
@tk.tool()
|
|
|
|
|
def tool_b(input: GrepInput, ctx):
|
|
|
|
|
"""Tool B."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert tk.slug == "MY_TK"
|
|
|
|
|
assert len(tk.tools) == 2
|
|
|
|
|
assert tk.tools[0].slug == "TOOL_A"
|
|
|
|
|
assert tk.tools[1].slug == "TOOL_B"
|
|
|
|
|
|
|
|
|
|
def test_toolkit_bare_decorator(self):
|
|
|
|
|
tk = ExperimentalToolkit(slug="TK2", name="TK2", description="Desc")
|
|
|
|
|
|
|
|
|
|
@tk.tool
|
|
|
|
|
def tool_c(input: GrepInput, ctx):
|
|
|
|
|
"""Tool C."""
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
assert len(tk.tools) == 1
|
|
|
|
|
|
|
|
|
|
def test_toolkit_slug_validation(self):
|
2026-03-20 12:48:03 -07:00
|
|
|
with pytest.raises(ValidationError, match="LOCAL_"):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
ExperimentalToolkit(slug="LOCAL_BAD", name="Bad", description="Desc")
|
|
|
|
|
|
|
|
|
|
def test_toolkit_name_required(self):
|
|
|
|
|
with pytest.raises(ValidationError, match="name is required"):
|
|
|
|
|
ExperimentalToolkit(slug="TK", name="", description="Desc")
|
|
|
|
|
|
|
|
|
|
def test_toolkit_via_experimental_api(self):
|
|
|
|
|
tk = exp.Toolkit(slug="API_TK", name="API TK", description="Via API")
|
|
|
|
|
assert isinstance(tk, ExperimentalToolkit)
|
|
|
|
|
assert tk.slug == "API_TK"
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
# Serialization tests
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSerialization:
|
|
|
|
|
def test_serialize_standalone_tool(self, grep_tool):
|
|
|
|
|
result = serialize_custom_tools([grep_tool])
|
|
|
|
|
assert len(result) == 1
|
|
|
|
|
assert result[0]["slug"] == "GREP"
|
|
|
|
|
assert result[0]["input_schema"]["type"] == "object"
|
|
|
|
|
assert "extends_toolkit" not in result[0]
|
|
|
|
|
|
|
|
|
|
def test_serialize_extension_tool(self, email_tool):
|
|
|
|
|
result = serialize_custom_tools([email_tool])
|
|
|
|
|
assert result[0]["extends_toolkit"] == "gmail"
|
|
|
|
|
|
2026-05-06 15:51:28 +05:30
|
|
|
def test_serialize_custom_tool_preload_hint(self, grep_tool):
|
|
|
|
|
preloaded = replace(grep_tool, preload=True)
|
|
|
|
|
result = serialize_custom_tools([preloaded])
|
|
|
|
|
assert result[0]["preload"] is True
|
|
|
|
|
|
2026-05-06 16:26:22 +05:30
|
|
|
def test_serialize_custom_tool_omits_redundant_preload_false(self, grep_tool):
|
|
|
|
|
search_only = replace(grep_tool, preload=False)
|
|
|
|
|
result = serialize_custom_tools([search_only])
|
|
|
|
|
assert "preload" not in result[0]
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
def test_serialize_toolkit(self, role_toolkit):
|
|
|
|
|
result = serialize_custom_toolkits([role_toolkit])
|
|
|
|
|
assert len(result) == 1
|
|
|
|
|
assert result[0]["slug"] == "ROLE_MANAGER"
|
|
|
|
|
assert len(result[0]["tools"]) == 1
|
|
|
|
|
|
2026-05-06 15:51:28 +05:30
|
|
|
def test_serialize_toolkit_preload_hint(self, role_toolkit):
|
|
|
|
|
role_toolkit.preload = True
|
|
|
|
|
result = serialize_custom_toolkits([role_toolkit])
|
|
|
|
|
assert result[0]["preload"] is True
|
|
|
|
|
assert result[0]["tools"][0]["preload"] is True
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
# Routing map tests
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCustomToolsMap:
|
|
|
|
|
def test_build_map_standalone(self, grep_tool):
|
|
|
|
|
m = build_custom_tools_map([grep_tool])
|
|
|
|
|
assert "LOCAL_GREP" in m.by_final_slug
|
|
|
|
|
|
|
|
|
|
def test_build_map_extension(self, email_tool):
|
|
|
|
|
m = build_custom_tools_map([email_tool])
|
|
|
|
|
assert "LOCAL_GMAIL_GET_EMAILS" in m.by_final_slug
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_build_map_toolkit(self, role_toolkit):
|
2026-03-20 12:48:03 -07:00
|
|
|
m = build_custom_tools_map([], [role_toolkit])
|
|
|
|
|
assert "LOCAL_ROLE_MANAGER_SET_ROLE" in m.by_final_slug
|
|
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
def test_allows_same_slug_in_different_toolkits(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map([], [alpha, beta])
|
|
|
|
|
|
|
|
|
|
assert set(m.by_final_slug) == {"LOCAL_ALPHA_GREP", "LOCAL_BETA_GREP"}
|
|
|
|
|
assert "GREP" not in m.by_original_slug
|
|
|
|
|
assert m.ambiguous_original_slugs == {"GREP"}
|
|
|
|
|
|
|
|
|
|
def test_ambiguous_original_slug_requires_final_slug(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
m = build_custom_tools_map([], [alpha, beta])
|
|
|
|
|
|
|
|
|
|
assert find_custom_tool(m, "LOCAL_ALPHA_GREP").toolkit == "ALPHA"
|
|
|
|
|
assert find_custom_tool(m, "LOCAL_BETA_GREP").toolkit == "BETA"
|
|
|
|
|
assert find_custom_tool(m, "GREP") is None
|
|
|
|
|
with pytest.raises(ValidationError, match="Ambiguous custom tool slug"):
|
|
|
|
|
assert_unambiguous_custom_tool_slug(m, "GREP")
|
|
|
|
|
|
|
|
|
|
def test_preload_rejects_ambiguous_original_slug(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
m = build_custom_tools_map([], [alpha, beta])
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="not supported in preload.tools"):
|
|
|
|
|
assert_no_custom_tool_slugs_in_preload(["grep"], m)
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_collision_detection(self, grep_tool):
|
2026-03-20 12:48:03 -07:00
|
|
|
with pytest.raises(ValidationError, match="collision"):
|
|
|
|
|
build_custom_tools_map([grep_tool, grep_tool])
|
|
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
def test_rejects_standalone_and_toolkit_same_slug(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="collision"):
|
|
|
|
|
build_custom_tools_map([alpha_tool], [beta])
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
class TestFindCustomTool:
|
|
|
|
|
def test_find_by_final_slug(self, grep_tool):
|
|
|
|
|
m = build_custom_tools_map([grep_tool])
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
assert find_custom_tool(m, "LOCAL_GREP") is not None
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
def test_find_case_insensitive(self, grep_tool):
|
|
|
|
|
m = build_custom_tools_map([grep_tool])
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
assert find_custom_tool(m, "grep") is not None
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
def test_find_nonexistent(self, grep_tool):
|
|
|
|
|
m = build_custom_tools_map([grep_tool])
|
|
|
|
|
assert find_custom_tool(m, "NONEXISTENT") is None
|
|
|
|
|
|
|
|
|
|
def test_find_none_map(self):
|
|
|
|
|
assert find_custom_tool(None, "GREP") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBuildMapFromResponse:
|
|
|
|
|
def test_builds_from_response(self, grep_tool, email_tool, role_toolkit):
|
|
|
|
|
mock_exp = MagicMock()
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
mock_ct1 = MagicMock(
|
|
|
|
|
slug="LOCAL_GREP", original_slug="GREP", extends_toolkit=None
|
|
|
|
|
)
|
|
|
|
|
mock_ct2 = MagicMock(
|
|
|
|
|
slug="LOCAL_GMAIL_GET_EMAILS",
|
|
|
|
|
original_slug="GET_EMAILS",
|
|
|
|
|
extends_toolkit="gmail",
|
|
|
|
|
)
|
2026-03-20 12:48:03 -07:00
|
|
|
mock_exp.custom_tools = [mock_ct1, mock_ct2]
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
mock_ctk = MagicMock(slug="ROLE_MANAGER")
|
|
|
|
|
mock_ctk.tools = [
|
|
|
|
|
MagicMock(slug="LOCAL_ROLE_MANAGER_SET_ROLE", original_slug="SET_ROLE")
|
|
|
|
|
]
|
2026-03-20 12:48:03 -07:00
|
|
|
mock_exp.custom_toolkits = [mock_ctk]
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map_from_response(
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
[grep_tool, email_tool], [role_toolkit], mock_exp
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
|
|
|
|
assert "LOCAL_GREP" in m.by_final_slug
|
|
|
|
|
assert "LOCAL_GMAIL_GET_EMAILS" in m.by_final_slug
|
|
|
|
|
assert "LOCAL_ROLE_MANAGER_SET_ROLE" in m.by_final_slug
|
|
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
def test_allows_same_slug_in_different_toolkits(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = []
|
|
|
|
|
mock_exp.custom_toolkits = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="ALPHA",
|
|
|
|
|
tools=[MagicMock(slug="LOCAL_ALPHA_GREP", original_slug="GREP")],
|
|
|
|
|
),
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="BETA",
|
|
|
|
|
tools=[MagicMock(slug="LOCAL_BETA_GREP", original_slug="GREP")],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map_from_response([], [alpha, beta], mock_exp)
|
|
|
|
|
|
|
|
|
|
assert set(m.by_final_slug) == {"LOCAL_ALPHA_GREP", "LOCAL_BETA_GREP"}
|
|
|
|
|
assert m.by_final_slug["LOCAL_ALPHA_GREP"].toolkit == "ALPHA"
|
|
|
|
|
assert m.by_final_slug["LOCAL_BETA_GREP"].toolkit == "BETA"
|
|
|
|
|
assert m.ambiguous_original_slugs == {"GREP"}
|
|
|
|
|
|
|
|
|
|
def _duplicate_toolkits(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
return alpha, beta
|
|
|
|
|
|
|
|
|
|
def test_rejects_toolkit_child_without_exact_match(self, duplicate_slug_tools):
|
|
|
|
|
alpha, beta = self._duplicate_toolkits(duplicate_slug_tools)
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = []
|
|
|
|
|
mock_exp.custom_toolkits = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="GAMMA",
|
|
|
|
|
tools=[MagicMock(slug="LOCAL_GAMMA_GREP", original_slug="GREP")],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="no exact local match"):
|
|
|
|
|
build_custom_tools_map_from_response([], [alpha, beta], mock_exp)
|
|
|
|
|
|
|
|
|
|
def test_never_binds_toolkit_child_to_another_toolkit(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, _ = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = []
|
|
|
|
|
mock_exp.custom_toolkits = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="BETA",
|
|
|
|
|
tools=[MagicMock(slug="LOCAL_BETA_GREP", original_slug="GREP")],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match='for toolkit "BETA"'):
|
|
|
|
|
build_custom_tools_map_from_response([], [alpha], mock_exp)
|
|
|
|
|
|
|
|
|
|
def test_standalone_falls_back_to_unique_bare_match(self, email_tool):
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="LOCAL_GMAIL_GET_EMAILS",
|
|
|
|
|
original_slug="GET_EMAILS",
|
|
|
|
|
extends_toolkit=None,
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
mock_exp.custom_toolkits = []
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map_from_response([email_tool], None, mock_exp)
|
|
|
|
|
|
|
|
|
|
assert m.by_final_slug["LOCAL_GMAIL_GET_EMAILS"].toolkit == "gmail"
|
|
|
|
|
|
|
|
|
|
def test_skips_response_tools_not_defined_locally(self, grep_tool):
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = [
|
|
|
|
|
MagicMock(slug="LOCAL_GREP", original_slug="GREP", extends_toolkit=None),
|
|
|
|
|
MagicMock(slug="LOCAL_STALE", original_slug="STALE", extends_toolkit=None),
|
|
|
|
|
]
|
|
|
|
|
mock_exp.custom_toolkits = []
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map_from_response([grep_tool], None, mock_exp)
|
|
|
|
|
|
|
|
|
|
assert set(m.by_final_slug) == {"LOCAL_GREP"}
|
|
|
|
|
|
|
|
|
|
def test_ambiguity_follows_local_definitions(self, duplicate_slug_tools):
|
|
|
|
|
alpha, beta = self._duplicate_toolkits(duplicate_slug_tools)
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = []
|
|
|
|
|
mock_exp.custom_toolkits = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="ALPHA",
|
|
|
|
|
tools=[MagicMock(slug="LOCAL_ALPHA_GREP", original_slug="GREP")],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map_from_response([], [alpha, beta], mock_exp)
|
|
|
|
|
|
|
|
|
|
assert set(m.by_final_slug) == {"LOCAL_ALPHA_GREP"}
|
|
|
|
|
assert "GREP" not in m.by_original_slug
|
|
|
|
|
assert m.ambiguous_original_slugs == {"GREP"}
|
|
|
|
|
assert find_custom_tool(m, "GREP") is None
|
|
|
|
|
|
|
|
|
|
def test_rejects_duplicate_qualified_response_entries(self, duplicate_slug_tools):
|
|
|
|
|
alpha, beta = self._duplicate_toolkits(duplicate_slug_tools)
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = []
|
|
|
|
|
mock_exp.custom_toolkits = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="ALPHA",
|
|
|
|
|
tools=[
|
|
|
|
|
MagicMock(slug="LOCAL_ALPHA_GREP", original_slug="GREP"),
|
|
|
|
|
MagicMock(slug="LOCAL_ALPHA_GREP_2", original_slug="GREP"),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="already registered for toolkit"):
|
|
|
|
|
build_custom_tools_map_from_response([], [alpha, beta], mock_exp)
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
# Execution tests
|
2026-03-20 12:48:03 -07:00
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecuteCustomTool:
|
|
|
|
|
def test_successful_execution(self, grep_tool):
|
|
|
|
|
m = build_custom_tools_map([grep_tool])
|
|
|
|
|
entry = find_custom_tool(m, "GREP")
|
|
|
|
|
result = execute_custom_tool(entry, {"pattern": "hello"}, MockSessionContext()) # type: ignore
|
|
|
|
|
assert result["successful"] is True
|
|
|
|
|
assert result["data"]["matches"] == ["hello"]
|
|
|
|
|
|
|
|
|
|
def test_validation_failure(self, grep_tool):
|
|
|
|
|
m = build_custom_tools_map([grep_tool])
|
|
|
|
|
entry = find_custom_tool(m, "GREP")
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
result = execute_custom_tool(entry, {}, MockSessionContext()) # type: ignore
|
2026-03-20 12:48:03 -07:00
|
|
|
assert result["successful"] is False
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
|
|
|
|
|
def test_execute_error(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def bad(input: GrepInput, ctx):
|
|
|
|
|
"""Bad."""
|
|
|
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
|
|
|
|
m = build_custom_tools_map([bad])
|
2026-03-20 12:48:03 -07:00
|
|
|
entry = find_custom_tool(m, "BAD")
|
|
|
|
|
result = execute_custom_tool(entry, {"pattern": "x"}, MockSessionContext()) # type: ignore
|
|
|
|
|
assert result["successful"] is False
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
assert "boom" in result["error"]
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
# SessionContextImpl tests
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSessionContextImpl:
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_sibling_routing(self, grep_tool):
|
2026-03-20 12:48:03 -07:00
|
|
|
m = build_custom_tools_map([grep_tool])
|
|
|
|
|
ctx = SessionContextImpl(
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
client=MagicMock(), user_id="u", session_id="s", custom_tools_map=m
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
|
|
|
|
result = ctx.execute("GREP", {"pattern": "test"})
|
2026-03-24 14:59:04 -07:00
|
|
|
assert isinstance(result, SessionExecuteResponse)
|
2026-03-24 14:34:18 -07:00
|
|
|
assert result.error is None
|
2026-03-24 14:59:04 -07:00
|
|
|
assert result.log_id == ""
|
2026-03-24 14:34:18 -07:00
|
|
|
assert result.data["matches"] == ["test"]
|
2026-03-20 12:48:03 -07:00
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
def test_sibling_routing_rejects_ambiguous_slug(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
m = build_custom_tools_map([], [alpha, beta])
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
ctx = SessionContextImpl(
|
|
|
|
|
client=mock_client, user_id="u", session_id="s", custom_tools_map=m
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="Ambiguous custom tool slug"):
|
|
|
|
|
ctx.execute("GREP", {"pattern": "x"})
|
|
|
|
|
mock_client.tool_router.session.execute.assert_not_called()
|
|
|
|
|
|
|
|
|
|
result = ctx.execute("LOCAL_BETA_GREP", {"pattern": "x"})
|
|
|
|
|
assert result.data["toolkit"] == "beta"
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_remote_fallback(self, grep_tool):
|
2026-03-20 12:48:03 -07:00
|
|
|
m = build_custom_tools_map([grep_tool])
|
|
|
|
|
mock_client = MagicMock()
|
2026-03-24 14:59:04 -07:00
|
|
|
mock_client.tool_router.session.execute.return_value = SessionExecuteResponse(
|
|
|
|
|
data={"remote": True}, error=None, log_id="log_123"
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
)
|
2026-03-20 12:48:03 -07:00
|
|
|
ctx = SessionContextImpl(
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
client=mock_client, user_id="u", session_id="s", custom_tools_map=m
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
ctx.execute("NONEXISTENT", {"arg": "val"})
|
2026-04-29 23:18:47 +05:30
|
|
|
mock_client.tool_router.session.execute.assert_called_once_with(
|
|
|
|
|
session_id="s",
|
|
|
|
|
tool_slug="NONEXISTENT",
|
|
|
|
|
arguments={"arg": "val"},
|
2026-05-06 18:44:40 +05:30
|
|
|
experimental=omit,
|
2026-04-29 23:18:47 +05:30
|
|
|
)
|
2026-03-20 12:48:03 -07:00
|
|
|
|
2026-05-06 18:17:43 +05:30
|
|
|
def test_remote_fallback_passes_inline_custom_tools(self):
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.tool_router.session.execute.return_value = SessionExecuteResponse(
|
|
|
|
|
data={"remote": True}, error=None, log_id="log_123"
|
|
|
|
|
)
|
|
|
|
|
inline_payload = {
|
|
|
|
|
"custom_tools": [
|
|
|
|
|
{
|
|
|
|
|
"slug": "GREP",
|
|
|
|
|
"name": "Grep",
|
|
|
|
|
"description": "Search local text",
|
|
|
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
ctx = SessionContextImpl(
|
|
|
|
|
client=mock_client,
|
|
|
|
|
user_id="u",
|
|
|
|
|
session_id="s",
|
|
|
|
|
inline_custom_tools_payload=inline_payload,
|
|
|
|
|
)
|
|
|
|
|
ctx.execute("GMAIL_SEND_EMAIL", {"to": "a@b.com"})
|
|
|
|
|
mock_client.tool_router.session.execute.assert_called_once_with(
|
|
|
|
|
session_id="s",
|
|
|
|
|
tool_slug="GMAIL_SEND_EMAIL",
|
|
|
|
|
arguments={"to": "a@b.com"},
|
|
|
|
|
experimental=inline_payload,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
def test_proxy_execute(self):
|
|
|
|
|
mock_client = MagicMock()
|
2026-03-24 14:59:04 -07:00
|
|
|
mock_client.tool_router.session.proxy_execute.return_value = (
|
|
|
|
|
SessionProxyExecuteResponse(
|
|
|
|
|
status=200, data={"ok": True}, headers={}, binary_data=None
|
|
|
|
|
)
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
ctx = SessionContextImpl(client=mock_client, user_id="u", session_id="s")
|
2026-03-20 12:48:03 -07:00
|
|
|
result = ctx.proxy_execute(
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
toolkit="gmail", endpoint="https://example.com", method="GET"
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
fix(python): own the proxy_execute response shape (#4180)
> ### ⚠️ 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>
2026-08-23 00:30:58 +02:00
|
|
|
assert result == {"status": 200, "data": {"ok": True}, "headers": {}}
|
|
|
|
|
assert "binary_data" not in result
|
|
|
|
|
|
|
|
|
|
def test_proxy_execute_narrows_status_to_int(self):
|
|
|
|
|
"""The generated model types ``status`` as ``float`` and pydantic coerces.
|
|
|
|
|
|
|
|
|
|
Equality alone cannot catch the leak, because ``200 == 200.0``. Only the
|
|
|
|
|
type assertion distinguishes ``200`` from the ``200.0`` a raw read returns.
|
|
|
|
|
"""
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.tool_router.session.proxy_execute.return_value = (
|
|
|
|
|
SessionProxyExecuteResponse(
|
|
|
|
|
status=200, data=None, headers=None, binary_data=None
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
ctx = SessionContextImpl(client=mock_client, user_id="u", session_id="s")
|
|
|
|
|
result = ctx.proxy_execute(
|
|
|
|
|
toolkit="gmail", endpoint="https://example.com", method="GET"
|
|
|
|
|
)
|
|
|
|
|
assert isinstance(result["status"], int)
|
|
|
|
|
assert result == {"status": 200, "data": None, "headers": None}
|
|
|
|
|
|
|
|
|
|
def test_proxy_execute_projects_binary_data(self):
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.tool_router.session.proxy_execute.return_value = (
|
|
|
|
|
SessionProxyExecuteResponse(
|
|
|
|
|
status=200,
|
|
|
|
|
data={"ok": True},
|
|
|
|
|
headers={"content-type": "application/pdf"},
|
|
|
|
|
binary_data=BinaryData(
|
|
|
|
|
content_type="application/pdf",
|
|
|
|
|
size=123,
|
|
|
|
|
url="https://example.com/file.pdf",
|
|
|
|
|
expires_at="2026-08-18T00:00:00Z",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
ctx = SessionContextImpl(client=mock_client, user_id="u", session_id="s")
|
|
|
|
|
result = ctx.proxy_execute(
|
|
|
|
|
toolkit="gmail", endpoint="https://example.com", method="GET"
|
|
|
|
|
)
|
|
|
|
|
assert result == {
|
|
|
|
|
"status": 200,
|
|
|
|
|
"data": {"ok": True},
|
|
|
|
|
"headers": {"content-type": "application/pdf"},
|
|
|
|
|
"binary_data": {
|
|
|
|
|
"content_type": "application/pdf",
|
|
|
|
|
"size": 123,
|
|
|
|
|
"url": "https://example.com/file.pdf",
|
|
|
|
|
"expires_at": "2026-08-18T00:00:00Z",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
assert isinstance(result["binary_data"]["size"], int)
|
|
|
|
|
|
|
|
|
|
def test_proxy_execute_binary_data_without_expiry(self):
|
|
|
|
|
"""``expires_at`` is optional on the generated model; the key stays present."""
|
|
|
|
|
mock_client = MagicMock()
|
|
|
|
|
mock_client.tool_router.session.proxy_execute.return_value = (
|
|
|
|
|
SessionProxyExecuteResponse(
|
|
|
|
|
status=200,
|
|
|
|
|
data=None,
|
|
|
|
|
headers=None,
|
|
|
|
|
binary_data=BinaryData(
|
|
|
|
|
content_type="image/png",
|
|
|
|
|
size=7,
|
|
|
|
|
url="https://example.com/file.png",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
ctx = SessionContextImpl(client=mock_client, user_id="u", session_id="s")
|
|
|
|
|
result = ctx.proxy_execute(
|
|
|
|
|
toolkit="gmail", endpoint="https://example.com", method="GET"
|
|
|
|
|
)
|
|
|
|
|
assert result["binary_data"] == {
|
|
|
|
|
"content_type": "image/png",
|
|
|
|
|
"size": 7,
|
|
|
|
|
"url": "https://example.com/file.png",
|
|
|
|
|
"expires_at": None,
|
|
|
|
|
}
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
# ToolRouterSession integration
|
2026-03-20 12:48:03 -07:00
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_session_deps(grep_tool, email_tool, role_toolkit):
|
|
|
|
|
return {
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
"client": MagicMock(),
|
|
|
|
|
"provider": MagicMock(),
|
|
|
|
|
"experimental": MagicMock(),
|
|
|
|
|
"tools_map": build_custom_tools_map([grep_tool, email_tool], [role_toolkit]),
|
2026-03-20 12:48:03 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def _session(deps, **overrides):
|
|
|
|
|
kwargs = dict(
|
|
|
|
|
client=deps["client"],
|
|
|
|
|
provider=deps["provider"],
|
feat(sdk): default-off auto file upload/download; add dangerouslyAllowAutoUploadDownloadFiles (#3260)
## 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.
2026-04-28 10:55:03 +05:30
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
session_id="s",
|
|
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=deps["experimental"],
|
|
|
|
|
custom_tools_map=deps["tools_map"],
|
|
|
|
|
user_id="u",
|
|
|
|
|
)
|
|
|
|
|
kwargs.update(overrides)
|
|
|
|
|
return ToolRouterSession(**kwargs)
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
class TestToolRouterSessionCustomTools:
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_execute_local(self, mock_session_deps):
|
|
|
|
|
s = _session(mock_session_deps)
|
|
|
|
|
result = s.execute("GREP", arguments={"pattern": "x"})
|
2026-03-24 14:25:51 -07:00
|
|
|
# Returns SessionExecuteResponse — same type as remote
|
2026-03-24 14:59:04 -07:00
|
|
|
assert isinstance(result, SessionExecuteResponse)
|
2026-03-24 14:25:51 -07:00
|
|
|
assert result.error is None
|
|
|
|
|
assert result.log_id == ""
|
|
|
|
|
assert result.data["matches"] == ["x"]
|
2026-03-20 12:48:03 -07:00
|
|
|
mock_session_deps["client"].tool_router.session.execute.assert_not_called()
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_execute_remote(self, mock_session_deps):
|
2026-03-24 14:59:04 -07:00
|
|
|
mock_response = SessionExecuteResponse(
|
|
|
|
|
data={"sent": True}, error=None, log_id="log_123"
|
|
|
|
|
)
|
2026-03-20 14:04:50 -07:00
|
|
|
mock_session_deps[
|
|
|
|
|
"client"
|
2026-03-23 17:15:53 -07:00
|
|
|
].tool_router.session.execute.return_value = mock_response
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
s = _session(mock_session_deps)
|
2026-03-23 17:15:53 -07:00
|
|
|
result = s.execute("GMAIL_SEND_EMAIL", arguments={"to": "a@b.com"})
|
2026-03-20 12:48:03 -07:00
|
|
|
mock_session_deps["client"].tool_router.session.execute.assert_called_once()
|
2026-04-29 23:18:47 +05:30
|
|
|
call_args = mock_session_deps["client"].tool_router.session.execute.call_args
|
|
|
|
|
assert call_args.kwargs["session_id"] == "s"
|
|
|
|
|
assert call_args.kwargs["tool_slug"] == "GMAIL_SEND_EMAIL"
|
|
|
|
|
assert call_args.kwargs["arguments"] == {"to": "a@b.com"}
|
|
|
|
|
assert "extra_body" not in call_args.kwargs
|
2026-04-30 17:52:44 +05:30
|
|
|
assert "enable_auto_workbench_offload" not in call_args.kwargs
|
2026-03-24 14:22:43 -07:00
|
|
|
# Remote returns client model as-is (backward compat, supports attribute access)
|
2026-03-24 14:59:04 -07:00
|
|
|
assert isinstance(result, SessionExecuteResponse)
|
2026-03-24 14:22:43 -07:00
|
|
|
assert result.data == {"sent": True}
|
|
|
|
|
assert result.log_id == "log_123"
|
2026-03-20 12:48:03 -07:00
|
|
|
|
2026-05-06 18:17:43 +05:30
|
|
|
def test_execute_remote_passes_inline_custom_tools(self, mock_session_deps):
|
|
|
|
|
mock_response = SessionExecuteResponse(
|
|
|
|
|
data={"sent": True}, error=None, log_id="log_123"
|
|
|
|
|
)
|
|
|
|
|
mock_session_deps[
|
|
|
|
|
"client"
|
|
|
|
|
].tool_router.session.execute.return_value = mock_response
|
|
|
|
|
inline_payload = {
|
|
|
|
|
"custom_tools": [
|
|
|
|
|
{
|
|
|
|
|
"slug": "GREP",
|
|
|
|
|
"name": "Grep",
|
|
|
|
|
"description": "Search local text",
|
|
|
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
s = _session(mock_session_deps, inline_custom_tools_payload=inline_payload)
|
|
|
|
|
s.execute("GMAIL_SEND_EMAIL", arguments={"to": "a@b.com"})
|
|
|
|
|
|
|
|
|
|
call_args = mock_session_deps["client"].tool_router.session.execute.call_args
|
|
|
|
|
assert call_args.kwargs["experimental"] == inline_payload
|
|
|
|
|
|
2026-03-24 14:59:04 -07:00
|
|
|
def test_proxy_execute(self, mock_session_deps):
|
2026-03-24 15:08:09 -07:00
|
|
|
mock_session_deps[
|
|
|
|
|
"client"
|
|
|
|
|
].tool_router.session.proxy_execute.return_value = SessionProxyExecuteResponse(
|
|
|
|
|
status=200, data={"ok": True}, headers={}, binary_data=None
|
2026-03-24 14:59:04 -07:00
|
|
|
)
|
|
|
|
|
s = _session(mock_session_deps)
|
|
|
|
|
result = s.proxy_execute(
|
|
|
|
|
toolkit="gmail", endpoint="https://example.com", method="GET"
|
|
|
|
|
)
|
fix(python): own the proxy_execute response shape (#4180)
> ### ⚠️ 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>
2026-08-23 00:30:58 +02:00
|
|
|
assert result == {"status": 200, "data": {"ok": True}, "headers": {}}
|
|
|
|
|
assert isinstance(result["status"], int)
|
|
|
|
|
assert "binary_data" not in result
|
|
|
|
|
|
|
|
|
|
def test_proxy_execute_projects_binary_data(self, mock_session_deps):
|
|
|
|
|
mock_session_deps[
|
|
|
|
|
"client"
|
|
|
|
|
].tool_router.session.proxy_execute.return_value = SessionProxyExecuteResponse(
|
|
|
|
|
status=200,
|
|
|
|
|
data={"ok": True},
|
|
|
|
|
headers={"content-type": "application/pdf"},
|
|
|
|
|
binary_data=BinaryData(
|
|
|
|
|
content_type="application/pdf",
|
|
|
|
|
size=123,
|
|
|
|
|
url="https://example.com/file.pdf",
|
|
|
|
|
expires_at="2026-08-18T00:00:00Z",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
s = _session(mock_session_deps)
|
|
|
|
|
result = s.proxy_execute(
|
|
|
|
|
toolkit="gmail", endpoint="https://example.com", method="GET"
|
|
|
|
|
)
|
|
|
|
|
assert result == {
|
|
|
|
|
"status": 200,
|
|
|
|
|
"data": {"ok": True},
|
|
|
|
|
"headers": {"content-type": "application/pdf"},
|
|
|
|
|
"binary_data": {
|
|
|
|
|
"content_type": "application/pdf",
|
|
|
|
|
"size": 123,
|
|
|
|
|
"url": "https://example.com/file.pdf",
|
|
|
|
|
"expires_at": "2026-08-18T00:00:00Z",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
assert isinstance(result["binary_data"]["size"], int)
|
2026-03-24 14:59:04 -07:00
|
|
|
|
2026-03-20 12:48:03 -07:00
|
|
|
def test_custom_tools_list(self, mock_session_deps):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
s = _session(mock_session_deps)
|
|
|
|
|
assert len(s.custom_tools()) == 3
|
|
|
|
|
|
|
|
|
|
def test_custom_tools_filter(self, mock_session_deps):
|
|
|
|
|
s = _session(mock_session_deps)
|
|
|
|
|
assert len(s.custom_tools(toolkit="gmail")) == 1
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
def test_custom_toolkits_list(self, mock_session_deps):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
s = _session(mock_session_deps)
|
|
|
|
|
tks = s.custom_toolkits()
|
|
|
|
|
assert len(tks) == 1
|
|
|
|
|
assert tks[0].slug == "ROLE_MANAGER"
|
|
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
def test_custom_toolkits_list_uses_qualified_slugs(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
custom_tools_map = build_custom_tools_map([], [alpha, beta])
|
|
|
|
|
s = ToolRouterSession(
|
|
|
|
|
client=MagicMock(),
|
|
|
|
|
provider=MagicMock(),
|
|
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
|
|
|
session_id="s",
|
|
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=MagicMock(),
|
|
|
|
|
custom_tools_map=custom_tools_map,
|
|
|
|
|
user_id="u",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
toolkits = s.custom_toolkits()
|
|
|
|
|
|
|
|
|
|
assert [tk.tools[0].slug for tk in toolkits] == [
|
|
|
|
|
"LOCAL_ALPHA_GREP",
|
|
|
|
|
"LOCAL_BETA_GREP",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_custom_toolkits_list_never_borrows_other_toolkit_slug(
|
|
|
|
|
self, duplicate_slug_tools
|
|
|
|
|
):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
mock_exp = MagicMock()
|
|
|
|
|
mock_exp.custom_tools = []
|
|
|
|
|
mock_exp.custom_toolkits = [
|
|
|
|
|
MagicMock(
|
|
|
|
|
slug="ALPHA",
|
|
|
|
|
tools=[MagicMock(slug="LOCAL_ALPHA_GREP", original_slug="GREP")],
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
custom_tools_map = build_custom_tools_map_from_response(
|
|
|
|
|
[], [alpha, beta], mock_exp
|
|
|
|
|
)
|
|
|
|
|
s = ToolRouterSession(
|
|
|
|
|
client=MagicMock(),
|
|
|
|
|
provider=MagicMock(),
|
|
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
|
|
|
session_id="s",
|
|
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=MagicMock(),
|
|
|
|
|
custom_tools_map=custom_tools_map,
|
|
|
|
|
user_id="u",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert [tk.tools[0].slug for tk in s.custom_toolkits()] == [
|
|
|
|
|
"LOCAL_ALPHA_GREP",
|
|
|
|
|
"GREP",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_execute_rejects_ambiguous_original_slug(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
custom_tools_map = build_custom_tools_map([], [alpha, beta])
|
|
|
|
|
client = MagicMock()
|
|
|
|
|
s = ToolRouterSession(
|
|
|
|
|
client=client,
|
|
|
|
|
provider=MagicMock(),
|
|
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
|
|
|
session_id="s",
|
|
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=MagicMock(),
|
|
|
|
|
custom_tools_map=custom_tools_map,
|
|
|
|
|
user_id="u",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="Ambiguous custom tool slug"):
|
|
|
|
|
s.execute("GREP", arguments={"pattern": "x"})
|
|
|
|
|
client.tool_router.session.execute.assert_not_called()
|
|
|
|
|
|
|
|
|
|
alpha_result = s.execute("LOCAL_ALPHA_GREP", arguments={"pattern": "x"})
|
|
|
|
|
beta_result = s.execute("LOCAL_BETA_GREP", arguments={"pattern": "x"})
|
|
|
|
|
assert alpha_result.data["toolkit"] == "alpha"
|
|
|
|
|
assert beta_result.data["toolkit"] == "beta"
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_empty_when_no_map(self):
|
|
|
|
|
s = ToolRouterSession(
|
2026-03-20 12:48:03 -07:00
|
|
|
client=MagicMock(),
|
|
|
|
|
provider=MagicMock(),
|
feat(sdk): default-off auto file upload/download; add dangerouslyAllowAutoUploadDownloadFiles (#3260)
## 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.
2026-04-28 10:55:03 +05:30
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
session_id="s",
|
2026-03-20 12:48:03 -07:00
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=MagicMock(),
|
|
|
|
|
)
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
assert s.custom_tools() == []
|
2026-03-20 12:48:03 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
# Multi-execute routing
|
2026-03-20 12:48:03 -07:00
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMultiExecuteRouting:
|
2026-05-06 18:17:43 +05:30
|
|
|
def _make_session(self, *tools, inline_custom_tools_payload=None):
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
m = build_custom_tools_map(list(tools))
|
|
|
|
|
return ToolRouterSession(
|
|
|
|
|
client=MagicMock(),
|
2026-03-20 12:48:03 -07:00
|
|
|
provider=MagicMock(),
|
feat(sdk): default-off auto file upload/download; add dangerouslyAllowAutoUploadDownloadFiles (#3260)
## 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.
2026-04-28 10:55:03 +05:30
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
session_id="s",
|
2026-03-20 12:48:03 -07:00
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=MagicMock(),
|
|
|
|
|
custom_tools_map=m,
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
user_id="u",
|
2026-05-06 18:17:43 +05:30
|
|
|
inline_custom_tools_payload=inline_custom_tools_payload,
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_single_local(self, grep_tool):
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
result = s._route_multi_execute(
|
|
|
|
|
{"tools": [{"tool_slug": "GREP", "arguments": {"pattern": "x"}}]},
|
|
|
|
|
MagicMock(),
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
|
|
|
|
assert result["successful"] is True
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
assert result["data"]["matches"] == ["x"]
|
|
|
|
|
|
fix(sdk): qualify custom toolkit child slug mapping across Python and TypeScript (#4311)
## 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>
2026-09-03 20:08:48 +08:00
|
|
|
def test_rejects_ambiguous_original_slug(self, duplicate_slug_tools):
|
|
|
|
|
alpha_tool, beta_tool = duplicate_slug_tools
|
|
|
|
|
alpha = ExperimentalToolkit(
|
|
|
|
|
slug="ALPHA", name="Alpha", description="Alpha tools"
|
|
|
|
|
)
|
|
|
|
|
alpha._tools.append(alpha_tool)
|
|
|
|
|
beta = ExperimentalToolkit(slug="BETA", name="Beta", description="Beta tools")
|
|
|
|
|
beta._tools.append(beta_tool)
|
|
|
|
|
s = ToolRouterSession(
|
|
|
|
|
client=MagicMock(),
|
|
|
|
|
provider=MagicMock(),
|
|
|
|
|
dangerously_allow_auto_upload_download_files=True,
|
|
|
|
|
session_id="s",
|
|
|
|
|
mcp=MagicMock(),
|
|
|
|
|
experimental=MagicMock(),
|
|
|
|
|
custom_tools_map=build_custom_tools_map([], [alpha, beta]),
|
|
|
|
|
user_id="u",
|
|
|
|
|
)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
backend = MagicMock()
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = backend
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValidationError, match="Ambiguous custom tool slug"):
|
|
|
|
|
s._route_multi_execute(
|
|
|
|
|
{
|
|
|
|
|
"tools": [
|
|
|
|
|
{
|
|
|
|
|
"tool_slug": "LOCAL_ALPHA_GREP",
|
|
|
|
|
"arguments": {"pattern": "x"},
|
|
|
|
|
},
|
|
|
|
|
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
tm,
|
|
|
|
|
)
|
|
|
|
|
backend.assert_not_called()
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_all_remote(self, grep_tool):
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
remote = {"data": {"results": []}, "error": None, "successful": True}
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
|
|
|
|
result = s._route_multi_execute(
|
2026-03-23 16:31:13 -07:00
|
|
|
{"tools": [{"tool_slug": "REMOTE", "arguments": {}}]}, tm
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
)
|
|
|
|
|
assert result == remote
|
|
|
|
|
|
2026-05-06 18:17:43 +05:30
|
|
|
def test_all_remote_passes_inline_custom_tools(self, grep_tool):
|
|
|
|
|
inline_payload = {
|
|
|
|
|
"custom_tools": [
|
|
|
|
|
{
|
|
|
|
|
"slug": "GREP",
|
|
|
|
|
"name": "Grep",
|
|
|
|
|
"description": "Search local text",
|
|
|
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
s = self._make_session(grep_tool, inline_custom_tools_payload=inline_payload)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
remote = {"data": {"results": []}, "error": None, "successful": True}
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
|
|
|
|
s._route_multi_execute(
|
|
|
|
|
{"tools": [{"tool_slug": "REMOTE", "arguments": {}}]}, tm
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.assert_called_once_with(
|
|
|
|
|
session_id="s",
|
|
|
|
|
modifiers=None,
|
|
|
|
|
inline_custom_tools_payload=inline_payload,
|
|
|
|
|
)
|
|
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_mixed(self, grep_tool):
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
remote = {
|
2026-03-20 14:04:50 -07:00
|
|
|
"data": {
|
|
|
|
|
"results": [
|
2026-03-24 15:29:24 -07:00
|
|
|
{"tool_slug": "R", "response": {"successful": True, "data": {}}},
|
|
|
|
|
],
|
|
|
|
|
"total_count": 1,
|
|
|
|
|
"success_count": 1,
|
|
|
|
|
"error_count": 0,
|
2026-03-20 14:04:50 -07:00
|
|
|
},
|
2026-03-20 12:48:03 -07:00
|
|
|
"error": None,
|
|
|
|
|
"successful": True,
|
|
|
|
|
}
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
|
|
|
|
result = s._route_multi_execute(
|
2026-03-20 12:48:03 -07:00
|
|
|
{
|
|
|
|
|
"tools": [
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
|
|
|
|
{"tool_slug": "REMOTE", "arguments": {}},
|
2026-03-20 12:48:03 -07:00
|
|
|
]
|
|
|
|
|
},
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
tm,
|
2026-03-20 12:48:03 -07:00
|
|
|
)
|
2026-03-23 17:45:32 -07:00
|
|
|
results = result["data"]["results"]
|
|
|
|
|
assert len(results) == 2
|
2026-09-08 22:28:24 +08:00
|
|
|
# Results preserve the original request order.
|
|
|
|
|
assert results[0]["tool_slug"] == "GREP"
|
|
|
|
|
assert results[1]["tool_slug"] == "R"
|
|
|
|
|
assert results[0]["index"] == 0
|
|
|
|
|
assert results[1]["index"] == 1
|
2026-03-24 15:29:24 -07:00
|
|
|
assert result["data"]["total_count"] == 2
|
|
|
|
|
assert result["data"]["success_count"] == 2
|
|
|
|
|
assert result["data"]["error_count"] == 0
|
2026-03-20 12:48:03 -07:00
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
def test_failure_propagated(self):
|
|
|
|
|
@exp.tool()
|
|
|
|
|
def ok_tool(input: GrepInput, ctx):
|
|
|
|
|
"""OK."""
|
|
|
|
|
return {"ok": True}
|
2026-03-20 12:48:03 -07:00
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
@exp.tool()
|
|
|
|
|
def bad_tool(input: GrepInput, ctx):
|
|
|
|
|
"""Bad."""
|
|
|
|
|
raise RuntimeError("boom")
|
2026-03-20 12:48:03 -07:00
|
|
|
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
s = self._make_session(ok_tool, bad_tool)
|
|
|
|
|
result = s._route_multi_execute(
|
2026-03-20 13:19:58 -07:00
|
|
|
{
|
|
|
|
|
"tools": [
|
feat(python)!: replace factory functions with decorator API for custom tools
BREAKING: Remove experimental_create_tool() and experimental_create_toolkit()
factory functions. Replace with decorator-based API on composio.experimental:
@composio.experimental.tool()
def grep(input: GrepInput, ctx):
"""Search for a pattern."""
return {"matches": []}
@composio.experimental.tool(extends_toolkit="gmail")
def create_draft(input: DraftInput, ctx):
"""Create a Gmail draft."""
return ctx.proxy_execute(toolkit="gmail", ...)
dev_tools = composio.experimental.Toolkit(
slug="DEV_TOOLS", name="Dev Tools", description="Dev utilities",
)
@dev_tools.tool()
def search_code(input: SearchInput, ctx):
"""Search code."""
return {"results": []}
Decorator infers slug (fn name uppercased), name (humanized), description
(docstring), and input_params (first BaseModel-typed parameter). All can
be overridden with explicit kwargs. Supports @bare, @tool(), and
@tool(extends_toolkit=...) forms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:42:04 -07:00
|
|
|
{"tool_slug": "OK_TOOL", "arguments": {"pattern": "x"}},
|
|
|
|
|
{"tool_slug": "BAD_TOOL", "arguments": {"pattern": "y"}},
|
2026-03-20 13:19:58 -07:00
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
MagicMock(),
|
|
|
|
|
)
|
|
|
|
|
assert result["successful"] is False
|
|
|
|
|
assert "1 out of 2" in result["error"]
|
2026-03-24 15:06:56 -07:00
|
|
|
|
|
|
|
|
def test_remote_null_data_does_not_crash(self, grep_tool):
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
remote = {"data": None, "error": None, "successful": True}
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
|
|
|
|
result = s._route_multi_execute(
|
|
|
|
|
{
|
|
|
|
|
"tools": [
|
|
|
|
|
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
|
|
|
|
{"tool_slug": "REMOTE", "arguments": {}},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
tm,
|
|
|
|
|
)
|
|
|
|
|
assert result["successful"] is True
|
|
|
|
|
assert result["data"]["results"][0]["tool_slug"] == "GREP"
|
|
|
|
|
|
fix(py): keep local results when remote multi-execute transport fails (#4386)
## 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
2026-09-08 18:11:25 +02:00
|
|
|
def test_remote_transport_failure_keeps_local_results(self, grep_tool):
|
|
|
|
|
"""A raised backend error becomes per-tool failures (matches TS)."""
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
|
|
|
|
|
def failing_backend(slug, args):
|
|
|
|
|
raise RuntimeError("remote unavailable")
|
|
|
|
|
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = failing_backend
|
|
|
|
|
result = s._route_multi_execute(
|
|
|
|
|
{
|
|
|
|
|
"tools": [
|
|
|
|
|
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
|
|
|
|
{"tool_slug": "REMOTE", "arguments": {}},
|
|
|
|
|
{"tool_slug": "OTHER_REMOTE", "arguments": {}},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
tm,
|
|
|
|
|
)
|
|
|
|
|
assert result["successful"] is False
|
|
|
|
|
assert result["error"] == "2 out of 3 tools failed"
|
|
|
|
|
results = result["data"]["results"]
|
|
|
|
|
assert [r["tool_slug"] for r in results] == ["GREP", "REMOTE", "OTHER_REMOTE"]
|
|
|
|
|
assert [r["index"] for r in results] == [0, 1, 2]
|
|
|
|
|
assert results[0]["response"] == {
|
|
|
|
|
"successful": True,
|
|
|
|
|
"data": {"matches": ["x"], "path": "."},
|
|
|
|
|
}
|
|
|
|
|
assert "error" not in results[0]
|
|
|
|
|
for entry in results[1:]:
|
|
|
|
|
assert entry["error"] == "remote unavailable"
|
|
|
|
|
assert entry["response"] == {
|
|
|
|
|
"successful": False,
|
|
|
|
|
"data": {},
|
|
|
|
|
"error": "remote unavailable",
|
|
|
|
|
}
|
|
|
|
|
assert result["data"]["total_count"] == 3
|
|
|
|
|
assert result["data"]["success_count"] == 1
|
|
|
|
|
assert result["data"]["error_count"] == 2
|
|
|
|
|
|
|
|
|
|
def test_remote_transport_failure_uses_fallback_message(self, grep_tool):
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
|
|
|
|
|
def failing_backend(slug, args):
|
|
|
|
|
raise RuntimeError()
|
|
|
|
|
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = failing_backend
|
|
|
|
|
result = s._route_multi_execute(
|
|
|
|
|
{
|
|
|
|
|
"tools": [
|
|
|
|
|
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
|
|
|
|
{"tool_slug": "REMOTE", "arguments": {}},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
tm,
|
|
|
|
|
)
|
|
|
|
|
assert result["successful"] is False
|
|
|
|
|
assert result["data"]["results"][1]["error"] == "Remote tool execution failed"
|
|
|
|
|
|
2026-03-24 15:06:56 -07:00
|
|
|
def test_remote_batch_error_without_item_errors_uses_batch_message(self, grep_tool):
|
|
|
|
|
s = self._make_session(grep_tool)
|
|
|
|
|
tm = MagicMock()
|
|
|
|
|
remote = {
|
|
|
|
|
"data": None,
|
|
|
|
|
"error": "Remote batch failed before per-tool results were produced",
|
|
|
|
|
"successful": False,
|
|
|
|
|
}
|
|
|
|
|
tm._wrap_execute_tool_for_tool_router.return_value = lambda slug, args: remote
|
|
|
|
|
result = s._route_multi_execute(
|
|
|
|
|
{
|
|
|
|
|
"tools": [
|
|
|
|
|
{"tool_slug": "GREP", "arguments": {"pattern": "x"}},
|
|
|
|
|
{"tool_slug": "REMOTE", "arguments": {}},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
tm,
|
|
|
|
|
)
|
|
|
|
|
assert result["successful"] is False
|
|
|
|
|
assert (
|
|
|
|
|
result["error"]
|
|
|
|
|
== "Remote batch failed before per-tool results were produced"
|
|
|
|
|
)
|