905 lines
32 KiB
Python
905 lines
32 KiB
Python
"""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)
|
|
"""
|
|
|
|
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 (
|
|
SessionProxyExecuteResponse,
|
|
)
|
|
from pydantic import BaseModel, Field
|
|
|
|
from composio.core.models.custom_tool import (
|
|
ExperimentalToolkit,
|
|
build_custom_tools_map,
|
|
build_custom_tools_map_from_response,
|
|
serialize_custom_toolkits,
|
|
serialize_custom_tools,
|
|
)
|
|
from composio.core.models.custom_tool_execution import (
|
|
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
|
|
|
|
|
|
@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"
|
|
|
|
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"
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
def test_collision_detection(self, grep_tool):
|
|
with pytest.raises(ValidationError, match="collision"):
|
|
build_custom_tools_map([grep_tool, grep_tool])
|
|
|
|
|
|
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
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# 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"]
|
|
|
|
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"
|
|
)
|
|
assert isinstance(result, SessionProxyExecuteResponse)
|
|
assert result.status == 200
|
|
|
|
|
|
# ────────────────────────────────────────────────────────────────
|
|
# 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"],
|
|
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"
|
|
)
|
|
assert isinstance(result, SessionProxyExecuteResponse)
|
|
assert result.status == 200
|
|
|
|
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"
|
|
|
|
def test_empty_when_no_map(self):
|
|
s = ToolRouterSession(
|
|
client=MagicMock(),
|
|
provider=MagicMock(),
|
|
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(),
|
|
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"]
|
|
|
|
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
|
|
# Remotes first, locals appended (matches TS)
|
|
assert results[0]["tool_slug"] == "R"
|
|
assert results[1]["tool_slug"] == "GREP"
|
|
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"
|
|
|
|
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"
|
|
)
|