Files

1430 lines
53 KiB
Python
Raw Permalink Normal View History

"""Tests for custom tools in tool router sessions.
Covers:
- Decorator API (inference, overrides, validation)
- Toolkit builder with @toolkit.tool() decorator
- 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
from unittest.mock import MagicMock
import pytest
from composio_client import omit
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,
SessionProxyExecuteResponse,
)
from pydantic import BaseModel, Field
from composio.core.models.custom_tool import (
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,
build_custom_tools_map,
build_custom_tools_map_from_response,
serialize_custom_toolkits,
serialize_custom_tools,
)
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,
execute_custom_tool,
find_custom_tool,
)
from composio.core.models.experimental import ExperimentalAPI
from composio.core.models.session_context import SessionContextImpl
from composio.core.models.tool_router_session import ToolRouterSession
from composio.exceptions import ValidationError
# ────────────────────────────────────────────────────────────────
# Fixtures
# ────────────────────────────────────────────────────────────────
exp = ExperimentalAPI()
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():
@exp.tool()
def grep(input: GrepInput, ctx):
"""Search for patterns in files."""
return {"matches": [input.pattern], "path": input.path}
return grep
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
@pytest.fixture
def email_tool():
@exp.tool(extends_toolkit="gmail")
def get_emails(input: EmailInput, ctx):
"""Get emails from Gmail."""
return {"emails": []}
return get_emails
@pytest.fixture
def set_role_tool():
@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
@pytest.fixture
def role_toolkit(set_role_tool):
tk = ExperimentalToolkit(
slug="ROLE_MANAGER", name="Role Manager", description="Manage user roles"
)
tk._tools.append(set_role_tool)
return tk
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": {}}
# ────────────────────────────────────────────────────────────────
# Decorator API tests
# ────────────────────────────────────────────────────────────────
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
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."
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."
)
def test_missing_docstring_raises(self):
with pytest.raises(ValidationError, match="description is required"):
@exp.tool()
def no_doc(input: GrepInput, ctx):
return {}
def test_missing_basemodel_annotation_raises(self):
with pytest.raises(ValidationError, match="BaseModel subclass"):
@exp.tool()
def bad(x: str, ctx):
"""Bad tool."""
return {}
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 {}
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
def test_async_function_rejected(self):
with pytest.raises(ValidationError, match="async"):
@exp.tool()
async def async_tool(input: GrepInput, ctx):
"""Async tool."""
return {}
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 {}
def test_root_model_rejected(self):
from pydantic import RootModel
class ListInput(RootModel[list[int]]):
pass
with pytest.raises(ValidationError, match="RootModel"):
@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
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", [])
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):
with pytest.raises(ValidationError, match="LOCAL_"):
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"
# ────────────────────────────────────────────────────────────────
# 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]
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
# ────────────────────────────────────────────────────────────────
# 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
def test_build_map_toolkit(self, role_toolkit):
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)
def test_collision_detection(self, grep_tool):
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])
class TestFindCustomTool:
def test_find_by_final_slug(self, grep_tool):
m = build_custom_tools_map([grep_tool])
assert find_custom_tool(m, "LOCAL_GREP") is not None
def test_find_case_insensitive(self, grep_tool):
m = build_custom_tools_map([grep_tool])
assert find_custom_tool(m, "grep") is not None
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()
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",
)
mock_exp.custom_tools = [mock_ct1, mock_ct2]
mock_ctk = MagicMock(slug="ROLE_MANAGER")
mock_ctk.tools = [
MagicMock(slug="LOCAL_ROLE_MANAGER_SET_ROLE", original_slug="SET_ROLE")
]
mock_exp.custom_toolkits = [mock_ctk]
m = build_custom_tools_map_from_response(
[grep_tool, email_tool], [role_toolkit], mock_exp
)
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)
# ────────────────────────────────────────────────────────────────
# Execution tests
# ────────────────────────────────────────────────────────────────
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")
result = execute_custom_tool(entry, {}, MockSessionContext()) # type: ignore
assert result["successful"] is False
def test_execute_error(self):
@exp.tool()
def bad(input: GrepInput, ctx):
"""Bad."""
raise RuntimeError("boom")
m = build_custom_tools_map([bad])
entry = find_custom_tool(m, "BAD")
result = execute_custom_tool(entry, {"pattern": "x"}, MockSessionContext()) # type: ignore
assert result["successful"] is False
assert "boom" in result["error"]
# ────────────────────────────────────────────────────────────────
# SessionContextImpl tests
# ────────────────────────────────────────────────────────────────
class TestSessionContextImpl:
def test_sibling_routing(self, grep_tool):
m = build_custom_tools_map([grep_tool])
ctx = SessionContextImpl(
client=MagicMock(), user_id="u", session_id="s", custom_tools_map=m
)
result = ctx.execute("GREP", {"pattern": "test"})
assert isinstance(result, SessionExecuteResponse)
assert result.error is None
assert result.log_id == ""
assert result.data["matches"] == ["test"]
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"
def test_remote_fallback(self, grep_tool):
m = build_custom_tools_map([grep_tool])
mock_client = MagicMock()
mock_client.tool_router.session.execute.return_value = SessionExecuteResponse(
data={"remote": True}, error=None, log_id="log_123"
)
ctx = SessionContextImpl(
client=mock_client, user_id="u", session_id="s", custom_tools_map=m
)
ctx.execute("NONEXISTENT", {"arg": "val"})
mock_client.tool_router.session.execute.assert_called_once_with(
session_id="s",
tool_slug="NONEXISTENT",
arguments={"arg": "val"},
experimental=omit,
)
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,
)
def test_proxy_execute(self):
mock_client = MagicMock()
mock_client.tool_router.session.proxy_execute.return_value = (
SessionProxyExecuteResponse(
status=200, data={"ok": True}, headers={}, 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"
)
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,
}
# ────────────────────────────────────────────────────────────────
# ToolRouterSession integration
# ────────────────────────────────────────────────────────────────
@pytest.fixture
def mock_session_deps(grep_tool, email_tool, role_toolkit):
return {
"client": MagicMock(),
"provider": MagicMock(),
"experimental": MagicMock(),
"tools_map": build_custom_tools_map([grep_tool, email_tool], [role_toolkit]),
}
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,
session_id="s",
mcp=MagicMock(),
experimental=deps["experimental"],
custom_tools_map=deps["tools_map"],
user_id="u",
)
kwargs.update(overrides)
return ToolRouterSession(**kwargs)
class TestToolRouterSessionCustomTools:
def test_execute_local(self, mock_session_deps):
s = _session(mock_session_deps)
result = s.execute("GREP", arguments={"pattern": "x"})
# Returns SessionExecuteResponse — same type as remote
assert isinstance(result, SessionExecuteResponse)
assert result.error is None
assert result.log_id == ""
assert result.data["matches"] == ["x"]
mock_session_deps["client"].tool_router.session.execute.assert_not_called()
def test_execute_remote(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
s = _session(mock_session_deps)
result = s.execute("GMAIL_SEND_EMAIL", arguments={"to": "a@b.com"})
mock_session_deps["client"].tool_router.session.execute.assert_called_once()
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
assert "enable_auto_workbench_offload" not in call_args.kwargs
# Remote returns client model as-is (backward compat, supports attribute access)
assert isinstance(result, SessionExecuteResponse)
assert result.data == {"sent": True}
assert result.log_id == "log_123"
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
def test_proxy_execute(self, mock_session_deps):
mock_session_deps[
"client"
].tool_router.session.proxy_execute.return_value = SessionProxyExecuteResponse(
status=200, data={"ok": True}, headers={}, binary_data=None
)
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)
def test_custom_tools_list(self, mock_session_deps):
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
def test_custom_toolkits_list(self, mock_session_deps):
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"
def test_empty_when_no_map(self):
s = ToolRouterSession(
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,
session_id="s",
mcp=MagicMock(),
experimental=MagicMock(),
)
assert s.custom_tools() == []
# ────────────────────────────────────────────────────────────────
# Multi-execute routing
# ────────────────────────────────────────────────────────────────
class TestMultiExecuteRouting:
def _make_session(self, *tools, inline_custom_tools_payload=None):
m = build_custom_tools_map(list(tools))
return ToolRouterSession(
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,
session_id="s",
mcp=MagicMock(),
experimental=MagicMock(),
custom_tools_map=m,
user_id="u",
inline_custom_tools_payload=inline_custom_tools_payload,
)
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(),
)
assert result["successful"] is True
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()
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(
{"tools": [{"tool_slug": "REMOTE", "arguments": {}}]}, tm
)
assert result == remote
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,
)
def test_mixed(self, grep_tool):
s = self._make_session(grep_tool)
tm = MagicMock()
remote = {
"data": {
"results": [
{"tool_slug": "R", "response": {"successful": True, "data": {}}},
],
"total_count": 1,
"success_count": 1,
"error_count": 0,
},
"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,
)
results = result["data"]["results"]
assert len(results) == 2
fix(python): preserve mixed multi-execute result order (#4310) ## Summary `ToolRouterSession._route_multi_execute` currently concatenates remote results before local results and assigns new indexes from that concatenated list. For a request such as `[LOCAL_TOOL, REMOTE_TOOL]`, callers receive `[REMOTE_TOOL, LOCAL_TOOL]`, so code that correlates `results[index]` with the requested tools can use the wrong result. This brings the Python implementation in line with the merged TypeScript behavior in [#3800](https://github.com/ComposioHQ/composio/pull/3800): preserve each tool's original request index, restore that order after local/remote execution, and then assign contiguous result indexes. Fixes # ## Changes - Preserve the original index on locally executed result entries. - Map remote sub-batch results back to their original request indexes before merging. - Sort the merged results by original index and re-index them sequentially. - Update the mixed local/remote regression test to assert request order and indexes. ## Type of change - [x] Bug fix - [ ] New feature - [ ] Refactor/Chore - [ ] Documentation - [ ] Breaking change ## How Has This Been Tested? - `uv run --locked --group dev pytest tests/test_custom_tools.py -q` — 69 passed. - `uv run --locked --group dev nox -s tst -- tests/test_custom_tools.py` — 69 passed. - `uv run --locked --group dev ruff --config config/ruff.toml check composio/core/models/tool_router_session.py tests/test_custom_tools.py` — passed. - Ruff format check on both changed files — passed. - Verified the regression test fails on the pre-fix implementation and passes after the fix. ## Screenshots (if applicable) Not applicable. ## Checklist - [x] I have read the Code of Conduct and this PR adheres to it - [x] I ran linters/tests locally and they passed - [ ] I updated documentation as needed - [x] I added tests or explain why not applicable - [ ] I added a changeset if this change affects published packages ## Additional context This change is limited to Python multi-execute result ordering. All-local and all-remote fast paths remain unchanged. No changeset is needed because this repository does not use Changesets for Python package changes. Signed-off-by: CoralGarden52 <2193436736@qq.com>
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
assert result["data"]["total_count"] == 2
assert result["data"]["success_count"] == 2
assert result["data"]["error_count"] == 0
def test_failure_propagated(self):
@exp.tool()
def ok_tool(input: GrepInput, ctx):
"""OK."""
return {"ok": True}
@exp.tool()
def bad_tool(input: GrepInput, ctx):
"""Bad."""
raise RuntimeError("boom")
s = self._make_session(ok_tool, bad_tool)
result = s._route_multi_execute(
{
"tools": [
{"tool_slug": "OK_TOOL", "arguments": {"pattern": "x"}},
{"tool_slug": "BAD_TOOL", "arguments": {"pattern": "y"}},
]
},
MagicMock(),
)
assert result["successful"] is False
assert "1 out of 2" in result["error"]
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"
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"
)