399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""Tests for provider-facing tool schema aliases."""
|
|
|
|
import asyncio
|
|
import copy
|
|
import importlib.util
|
|
import inspect
|
|
import re
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from composio.exceptions import InvalidSchemaError
|
|
from composio.utils.shared import (
|
|
alias_tool_input_schema,
|
|
json_schema_to_model,
|
|
substitute_reserved_python_keywords,
|
|
)
|
|
|
|
|
|
PYTHON_ROOT = Path(__file__).resolve().parents[1]
|
|
ANTHROPIC_PROPERTY_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$")
|
|
|
|
|
|
def _load_module(monkeypatch, module_name: str, path: Path):
|
|
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
assert spec is not None
|
|
assert spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
monkeypatch.setitem(sys.modules, module_name, module)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_alias_tool_input_schema_restores_nested_aliases_without_mutating_schema():
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string"},
|
|
"payload": {
|
|
"type": "object",
|
|
"properties": {"class": {"type": "string"}},
|
|
"required": ["class"],
|
|
},
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"for": {"type": "string"}},
|
|
"required": ["for"],
|
|
},
|
|
},
|
|
},
|
|
"required": ["from", "payload"],
|
|
}
|
|
original = copy.deepcopy(schema)
|
|
|
|
aliases = alias_tool_input_schema(schema)
|
|
|
|
assert schema == original
|
|
assert list(aliases.schema["properties"]) == ["from_rs", "payload", "items"]
|
|
assert aliases.schema["required"] == ["from_rs", "payload"]
|
|
assert aliases.schema["properties"]["payload"]["required"] == ["class_rs"]
|
|
assert aliases.schema["properties"]["items"]["items"]["required"] == ["for_rs"]
|
|
|
|
arguments = {
|
|
"from_rs": "sender@example.com",
|
|
"payload": {"class_rs": "primary"},
|
|
"items": [{"for_rs": "recipient@example.com"}],
|
|
}
|
|
assert aliases.restore_arguments(arguments) == {
|
|
"from": "sender@example.com",
|
|
"payload": {"class": "primary"},
|
|
"items": [{"for": "recipient@example.com"}],
|
|
}
|
|
|
|
|
|
def test_alias_tool_input_schema_rejects_duplicate_aliases():
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string"},
|
|
"from_rs": {"type": "string"},
|
|
},
|
|
}
|
|
|
|
with pytest.raises(InvalidSchemaError, match="duplicate Python parameter alias"):
|
|
alias_tool_input_schema(schema)
|
|
|
|
|
|
def test_legacy_keyword_helpers_use_tool_schema_aliases():
|
|
long_name = "x" * 80
|
|
schema = {
|
|
"title": "ODataParams",
|
|
"type": "object",
|
|
"properties": {
|
|
"$top": {"type": "integer"},
|
|
"@microsoft.graph.conflictBehavior": {"type": "string"},
|
|
long_name: {"type": "string"},
|
|
},
|
|
"required": ["$top", long_name],
|
|
}
|
|
|
|
aliased_schema, aliases = substitute_reserved_python_keywords(schema)
|
|
|
|
aliased_names = list(aliased_schema["properties"])
|
|
assert aliased_names[0] == "param_top"
|
|
assert aliased_names[1] == "param_microsoft_graph_conflictBehavior"
|
|
assert len(aliased_names[2]) == 64
|
|
assert all(ANTHROPIC_PROPERTY_RE.fullmatch(name) for name in aliased_names)
|
|
assert aliased_schema["required"] == ["param_top", aliased_names[2]]
|
|
assert aliases["param_top"] == "$top"
|
|
assert aliases["param_microsoft_graph_conflictBehavior"] == (
|
|
"@microsoft.graph.conflictBehavior"
|
|
)
|
|
assert aliases[aliased_names[2]] == long_name
|
|
model = json_schema_to_model(aliased_schema)
|
|
assert "param_top" in model.model_fields
|
|
|
|
|
|
def test_alias_tool_input_schema_dereferences_refs_before_aliasing():
|
|
schema = {
|
|
"$ref": "#/$defs/SearchParams",
|
|
"$defs": {
|
|
"SearchParams": {
|
|
"type": "object",
|
|
"properties": {
|
|
"filter": {"$ref": "#/$defs/ODataFilter"},
|
|
},
|
|
"required": ["filter"],
|
|
},
|
|
"ODataFilter": {
|
|
"type": "object",
|
|
"properties": {"$top": {"type": "integer"}},
|
|
"required": ["$top"],
|
|
},
|
|
},
|
|
}
|
|
|
|
aliases = alias_tool_input_schema(schema)
|
|
|
|
assert "$defs" not in aliases.schema
|
|
assert aliases.schema["required"] == ["filter"]
|
|
filter_schema = aliases.schema["properties"]["filter"]
|
|
assert list(filter_schema["properties"]) == ["param_top"]
|
|
assert filter_schema["required"] == ["param_top"]
|
|
assert aliases.restore_arguments({"filter": {"param_top": 10}}) == {
|
|
"filter": {"$top": 10}
|
|
}
|
|
|
|
|
|
def test_gemini_manual_response_restores_provider_visible_aliases(monkeypatch):
|
|
google_module = types.ModuleType("google")
|
|
genai_module = types.ModuleType("google.genai")
|
|
genai_types_module = types.ModuleType("google.genai.types")
|
|
|
|
class FunctionResponse:
|
|
def __init__(self, name, response):
|
|
self.name = name
|
|
self.response = response
|
|
|
|
class Part:
|
|
def __init__(self, function_response):
|
|
self.function_response = function_response
|
|
|
|
genai_types_module.FunctionResponse = FunctionResponse
|
|
genai_types_module.Part = Part
|
|
genai_module.types = genai_types_module
|
|
google_module.genai = genai_module
|
|
monkeypatch.setitem(sys.modules, "google", google_module)
|
|
monkeypatch.setitem(sys.modules, "google.genai", genai_module)
|
|
monkeypatch.setitem(sys.modules, "google.genai.types", genai_types_module)
|
|
|
|
provider_module = _load_module(
|
|
monkeypatch,
|
|
"test_composio_gemini_provider",
|
|
PYTHON_ROOT / "providers/gemini/composio_gemini/provider.py",
|
|
)
|
|
provider = provider_module.GeminiProvider()
|
|
execute_tool = Mock(return_value={"successful": True, "data": {"ok": True}})
|
|
tool = SimpleNamespace(
|
|
slug="TOOL_WITH_RESERVED",
|
|
description="Tool with reserved parameters",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {"for": {"type": "string"}},
|
|
"required": ["for"],
|
|
},
|
|
)
|
|
provider.wrap_tools([tool], execute_tool)
|
|
|
|
response = SimpleNamespace(
|
|
candidates=[
|
|
SimpleNamespace(
|
|
content=SimpleNamespace(
|
|
parts=[
|
|
SimpleNamespace(
|
|
function_call=SimpleNamespace(
|
|
name="TOOL_WITH_RESERVED",
|
|
args={"for_rs": "recipient@example.com"},
|
|
)
|
|
)
|
|
]
|
|
)
|
|
)
|
|
]
|
|
)
|
|
|
|
function_responses, executed = provider.handle_response(response)
|
|
|
|
assert executed is True
|
|
assert function_responses[0].function_response.name == "TOOL_WITH_RESERVED"
|
|
execute_tool.assert_called_once_with(
|
|
slug="TOOL_WITH_RESERVED", arguments={"for": "recipient@example.com"}
|
|
)
|
|
|
|
|
|
def test_google_adk_wrap_tool_aliases_signature_and_restores_arguments(monkeypatch):
|
|
google_module = types.ModuleType("google")
|
|
adk_module = types.ModuleType("google.adk")
|
|
tools_module = types.ModuleType("google.adk.tools")
|
|
|
|
class FunctionTool:
|
|
def __init__(self, func):
|
|
self.func = func
|
|
|
|
tools_module.FunctionTool = FunctionTool
|
|
adk_module.tools = tools_module
|
|
google_module.adk = adk_module
|
|
monkeypatch.setitem(sys.modules, "google", google_module)
|
|
monkeypatch.setitem(sys.modules, "google.adk", adk_module)
|
|
monkeypatch.setitem(sys.modules, "google.adk.tools", tools_module)
|
|
|
|
provider_module = _load_module(
|
|
monkeypatch,
|
|
"test_composio_google_adk_provider",
|
|
PYTHON_ROOT / "providers/google_adk/composio_google_adk/provider.py",
|
|
)
|
|
provider = provider_module.GoogleAdkProvider()
|
|
execute_tool = Mock(return_value={"successful": True, "data": {"ok": True}})
|
|
tool = SimpleNamespace(
|
|
slug="TOOL_WITH_RESERVED",
|
|
description="Tool with reserved parameters",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string", "description": "Sender"},
|
|
"limit": {"type": "integer", "description": "Limit"},
|
|
},
|
|
"required": ["from"],
|
|
},
|
|
)
|
|
|
|
wrapped = provider.wrap_tool(tool, execute_tool)
|
|
|
|
assert list(inspect.signature(wrapped.func).parameters) == ["from_rs", "limit"]
|
|
assert "from_rs: Sender" in (wrapped.func.__doc__ or "")
|
|
wrapped.func(from_rs="sender@example.com", limit=10)
|
|
execute_tool.assert_called_once_with(
|
|
slug="TOOL_WITH_RESERVED",
|
|
arguments={"from": "sender@example.com", "limit": 10},
|
|
)
|
|
|
|
|
|
def test_anthropic_wrap_tool_aliases_schema_and_restores_arguments(monkeypatch):
|
|
anthropic_module = types.ModuleType("anthropic")
|
|
types_module = types.ModuleType("anthropic.types")
|
|
beta_module = types.ModuleType("anthropic.types.beta")
|
|
beta_tool_use_module = types.ModuleType("anthropic.types.beta.beta_tool_use_block")
|
|
message_module = types.ModuleType("anthropic.types.message")
|
|
tool_param_module = types.ModuleType("anthropic.types.tool_param")
|
|
tool_use_module = types.ModuleType("anthropic.types.tool_use_block")
|
|
|
|
class BetaToolUseBlock:
|
|
pass
|
|
|
|
class Message:
|
|
def __init__(self, **kwargs):
|
|
self.__dict__.update(kwargs)
|
|
|
|
class ToolUseBlock:
|
|
pass
|
|
|
|
beta_tool_use_module.BetaToolUseBlock = BetaToolUseBlock
|
|
message_module.Message = Message
|
|
tool_param_module.ToolParam = dict
|
|
tool_use_module.ToolUseBlock = ToolUseBlock
|
|
monkeypatch.setitem(sys.modules, "anthropic", anthropic_module)
|
|
monkeypatch.setitem(sys.modules, "anthropic.types", types_module)
|
|
monkeypatch.setitem(sys.modules, "anthropic.types.beta", beta_module)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"anthropic.types.beta.beta_tool_use_block",
|
|
beta_tool_use_module,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "anthropic.types.message", message_module)
|
|
monkeypatch.setitem(sys.modules, "anthropic.types.tool_param", tool_param_module)
|
|
monkeypatch.setitem(sys.modules, "anthropic.types.tool_use_block", tool_use_module)
|
|
|
|
provider_module = _load_module(
|
|
monkeypatch,
|
|
"test_composio_anthropic_provider",
|
|
PYTHON_ROOT / "providers/anthropic/composio_anthropic/provider.py",
|
|
)
|
|
provider = provider_module.AnthropicProvider()
|
|
provider.execute_tool = Mock(return_value={"successful": True})
|
|
long_name = "x" * 80
|
|
tool = SimpleNamespace(
|
|
slug="TOOL_WITH_ODATA",
|
|
description="Tool with OData parameters",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"$top": {"type": "integer"},
|
|
"@microsoft.graph.conflictBehavior": {"type": "string"},
|
|
long_name: {"type": "string"},
|
|
},
|
|
"required": ["$top", long_name],
|
|
},
|
|
)
|
|
|
|
wrapped = provider.wrap_tool(tool)
|
|
|
|
aliased_names = list(wrapped["input_schema"]["properties"])
|
|
assert aliased_names[0] == "param_top"
|
|
assert aliased_names[1] == "param_microsoft_graph_conflictBehavior"
|
|
assert len(aliased_names[2]) == 64
|
|
assert all(ANTHROPIC_PROPERTY_RE.fullmatch(name) for name in aliased_names)
|
|
|
|
tool_call = SimpleNamespace(
|
|
name="TOOL_WITH_ODATA",
|
|
input={
|
|
"param_top": 10,
|
|
"param_microsoft_graph_conflictBehavior": "rename",
|
|
aliased_names[2]: "value",
|
|
},
|
|
)
|
|
provider.execute_tool_call(user_id="user", tool_call=tool_call)
|
|
|
|
provider.execute_tool.assert_called_once_with(
|
|
slug="TOOL_WITH_ODATA",
|
|
arguments={
|
|
"$top": 10,
|
|
"@microsoft.graph.conflictBehavior": "rename",
|
|
long_name: "value",
|
|
},
|
|
modifiers=None,
|
|
user_id="user",
|
|
)
|
|
|
|
|
|
def test_claude_agent_sdk_wrap_tool_aliases_schema_and_restores_arguments(monkeypatch):
|
|
claude_agent_sdk_module = types.ModuleType("claude_agent_sdk")
|
|
|
|
def sdk_tool(name, description, input_schema):
|
|
def decorator(fn):
|
|
fn._tool_name = name
|
|
fn._tool_description = description
|
|
fn._input_schema = input_schema
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
claude_agent_sdk_module.McpSdkServerConfig = dict
|
|
claude_agent_sdk_module.SdkMcpTool = object
|
|
claude_agent_sdk_module.create_sdk_mcp_server = Mock()
|
|
claude_agent_sdk_module.tool = sdk_tool
|
|
monkeypatch.setitem(sys.modules, "claude_agent_sdk", claude_agent_sdk_module)
|
|
|
|
provider_module = _load_module(
|
|
monkeypatch,
|
|
"test_composio_claude_agent_sdk_provider",
|
|
PYTHON_ROOT
|
|
/ "providers/claude_agent_sdk/composio_claude_agent_sdk/provider.py",
|
|
)
|
|
provider = provider_module.ClaudeAgentSDKProvider()
|
|
execute_tool = Mock(return_value={"successful": True})
|
|
tool = SimpleNamespace(
|
|
slug="TOOL_WITH_ODATA",
|
|
description="Tool with OData parameters",
|
|
input_parameters={
|
|
"type": "object",
|
|
"properties": {"$top": {"type": "integer"}},
|
|
"required": ["$top"],
|
|
},
|
|
)
|
|
|
|
wrapped = provider.wrap_tool(tool, execute_tool)
|
|
|
|
assert list(wrapped._input_schema["properties"]) == ["param_top"]
|
|
assert wrapped._input_schema["required"] == ["param_top"]
|
|
result = asyncio.run(wrapped({"param_top": 5}))
|
|
|
|
assert result["content"][0]["type"] == "text"
|
|
execute_tool.assert_called_once_with("TOOL_WITH_ODATA", {"$top": 5})
|