Files
composiohq--composio/python/tests/test_custom_tools.py
T
2026-07-13 12:38:34 +08:00

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"
)