diff --git a/contrib/langchain/.gitignore b/contrib/langchain/.gitignore deleted file mode 100644 index 4865054a..00000000 --- a/contrib/langchain/.gitignore +++ /dev/null @@ -1,175 +0,0 @@ -.DS_Store -credentials.yaml -docker/credentials.yaml - -*.lock - -# example data -examples/data -scratch - - -docs/source - -# From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ diff --git a/contrib/langchain/LICENSE b/contrib/langchain/LICENSE deleted file mode 100644 index dfbb8b76..00000000 --- a/contrib/langchain/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025, Arcade AI - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/contrib/langchain/Makefile b/contrib/langchain/Makefile deleted file mode 100644 index 761be0eb..00000000 --- a/contrib/langchain/Makefile +++ /dev/null @@ -1,47 +0,0 @@ -.PHONY: help - -help: - @echo "🛠️ github Commands:\n" - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' - - -.PHONY: install -install: ## Install the uv environment and install all packages with dependencies - @echo "🚀 Creating virtual environment and installing all packages using uv" - @uv sync --active --all-extras --no-sources - @uv run pre-commit install - @echo "✅ All packages and dependencies installed via uv" - -.PHONY: build -build: clean-build ## Build wheel file using uv - @echo "🚀 Creating wheel file" - uv build - -.PHONY: clean-build -clean-build: ## clean build artifacts - @echo "🗑️ Cleaning dist directory" - rm -rf dist - -.PHONY: test -test: ## Test the code with pytest - @echo "🚀 Testing code: Running pytest" - @uv run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml - -.PHONY: coverage -coverage: ## Generate coverage report - @echo "coverage report" - coverage report - @echo "Generating coverage report" - coverage html - -.PHONY: bump-version -bump-version: ## Bump the version in the pyproject.toml file by a patch version - @echo "🚀 Bumping version in pyproject.toml" - uv version --bump patch - -.PHONY: check -check: ## Run code quality tools. - @echo "🚀 Linting code: Running pre-commit" - @uv run pre-commit run -a - @echo "🚀 Static type checking: Running mypy" - @uv run mypy --config-file=pyproject.toml diff --git a/contrib/langchain/README.md b/contrib/langchain/README.md deleted file mode 100644 index 22dfcf85..00000000 --- a/contrib/langchain/README.md +++ /dev/null @@ -1,175 +0,0 @@ -
-- Arcade Documentation • - Servers • - Python Client • - JavaScript Client -
- -## Overview - -`langchain-arcade` allows you to use Arcade tools in your LangChain and LangGraph applications. This integration provides a simple way to access Arcade's extensive toolkit ecosystem, including tools for search, email, document processing, and more. - -## Installation - -```bash -pip install langchain-arcade -``` - -## Basic Usage - -### 1. Initialize the Tool Manager - -The `ToolManager` is the main entry point for working with Arcade tools in LangChain: - -```python -import os -from langchain_arcade import ToolManager - -# Initialize with your API key -manager = ToolManager(api_key=os.environ["ARCADE_API_KEY"]) - -# Initialize with specific tools or toolkits -tools = manager.init_tools( - tools=["Web.ScrapeUrl"], # Individual tools - toolkits=["Search"] # All tools from a toolkit -) - -# Convert to LangChain tools -langchain_tools = manager.to_langchain() -``` - -### 2. Use with LangGraph - -```bash -pip install langgraph -``` - -Here's a simple example of using Arcade tools with LangGraph: - -```python -from langchain_openai import ChatOpenAI -from langgraph.checkpoint.memory import MemorySaver -from langgraph.prebuilt import create_react_agent - -# Create a LangGraph agent -model = ChatOpenAI(model="gpt-4o") -memory = MemorySaver() -graph = create_react_agent(model, tools, checkpointer=memory) - -config = {"configurable": {"thread_id": "1", "user_id": "user@example.com"}} -user_input = {"messages": [("user", "List my important emails")]} - -for chunk in graph.stream(user_input, config, stream_mode="values"): - print(chunk["messages"][-1].content) -``` - -## Using Tools with Authorization in LangGraph - -Many Arcade tools require user authorization. Here's how to handle it: - -### 1. Using with prebuilt agents - -```python -import os - -from langchain_arcade import ToolManager -from langchain_openai import ChatOpenAI -from langgraph.prebuilt import create_react_agent - -# Initialize tools -manager = ToolManager(api_key=os.environ["ARCADE_API_KEY"]) -manager.init_tools(toolkits=["Github"]) -tools = manager.to_langchain(use_interrupts=True) - -# Create agent -model = ChatOpenAI(model="gpt-4o") -graph = create_react_agent(model, tools) - -# Run the agent with the "user_id" field in the config -# IMPORTANT the "user_id" field is required for tools that require user authorization -config = {"configurable": {"user_id": "user@lgexample.com"}} -user_input = {"messages": [("user", "Star the arcadeai/arcade-mcp repository on GitHub")]} - -for chunk in graph.stream(user_input, config, debug=True): - if chunk.get("__interrupt__"): - # print the authorization url - print(chunk["__interrupt__"][0].value) - # visit the URL to authorize the tool - # once you have authorized the tool, you can run again and the agent will continue - elif chunk.get("agent"): - print(chunk["agent"]["messages"][-1].content) - -# see the functional example for continuing the agent after authorization -# and for handling authorization errors gracefully - -``` - -See the Functional examples in the [examples directory](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/langchain) that continue the agent after authorization and handle authorization errors gracefully. - -### Async Support - -For asynchronous applications, use `AsyncToolManager`: - -```python -import asyncio -from langchain_arcade import AsyncToolManager - -async def main(): - manager = AsyncToolManager(api_key=os.environ["ARCADE_API_KEY"]) - await manager.init_tools(toolkits=["Google"]) - tools = await manager.to_langchain() - - # Use tools with async LangChain/LangGraph components - -asyncio.run(main()) -``` - -## Tool Authorization Flow - -Many Arcade tools require user authorization. This can be handled in many ways but the `ToolManager` provides a simple flow that can be used with prebuilt agents and also the functional API. The typical flow is: - -1. Attempt to use a tool that requires authorization -2. Check the state for interrupts from the `NodeInterrupt` exception (or Command) -3. Call `manager.authorize(tool_name, user_id)` to get an authorization URL -4. Present the URL to the user -5. Call `manager.wait_for_auth(auth_response.id)` to wait for completion -6. Resume the agent execution - -## Available Toolkits - -Arcade provides many toolkits including: - -- `Search`: Google search, Bing search -- `Google`: Gmail, Google Drive, Google Calendar -- `Web`: Crawling, scraping, etc -- `Github`: Repository operations -- `Slack`: Sending messages to Slack -- `Linkedin`: Posting to Linkedin -- `X`: Posting and reading tweets on X -- And many more - -For a complete list, see the [Arcade Toolkits documentation](https://docs.arcade.dev/en/resources/integrations). - -## More Examples - -For more examples, see the [examples directory](https://github.com/ArcadeAI/arcade-mcp/tree/main/examples/langchain). diff --git a/contrib/langchain/langchain_arcade/__init__.py b/contrib/langchain/langchain_arcade/__init__.py deleted file mode 100644 index 90af2fdf..00000000 --- a/contrib/langchain/langchain_arcade/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .manager import ArcadeToolManager, AsyncToolManager, ToolManager - -__all__ = [ - "ArcadeToolManager", # Deprecated - "AsyncToolManager", - "ToolManager", -] diff --git a/contrib/langchain/langchain_arcade/_utilities.py b/contrib/langchain/langchain_arcade/_utilities.py deleted file mode 100644 index 1176cb3c..00000000 --- a/contrib/langchain/langchain_arcade/_utilities.py +++ /dev/null @@ -1,313 +0,0 @@ -from typing import Any, Callable, Union - -from arcadepy import NOT_GIVEN, Arcade, AsyncArcade -from arcadepy.types import ExecuteToolResponse, ToolDefinition -from langchain_core.runnables import RunnableConfig -from langchain_core.tools import StructuredTool -from pydantic import BaseModel, Field, create_model - -# Check if LangGraph is enabled -LANGGRAPH_ENABLED = True -try: - from langgraph.errors import NodeInterrupt -except ImportError: - LANGGRAPH_ENABLED = False - -# Mapping of Arcade value types to Python types -TYPE_MAPPING = { - "string": str, - "number": float, - "integer": int, - "boolean": bool, - "array": list, - "json": dict, -} - - -def get_python_type(val_type: str) -> Any: - """Map Arcade value types to Python types. - - Args: - val_type: The value type as a string. - - Returns: - Corresponding Python type. - """ - _type = TYPE_MAPPING.get(val_type) - if _type is None: - raise ValueError(f"Invalid value type: {val_type}") - return _type - - -def tool_definition_to_pydantic_model(tool_def: ToolDefinition) -> type[BaseModel]: - """Convert a ToolDefinition's inputs into a Pydantic BaseModel. - - Args: - tool_def: The ToolDefinition object to convert. - - Returns: - A Pydantic BaseModel class representing the tool's input schema. - """ - try: - fields: dict[str, Any] = {} - for param in tool_def.input.parameters or []: - param_type = get_python_type(param.value_schema.val_type) - if param_type == list and param.value_schema.inner_val_type: # noqa: E721 - inner_type: type[Any] = get_python_type( - param.value_schema.inner_val_type - ) - param_type = list[inner_type] # type: ignore[valid-type] - param_description = param.description or "No description provided." - default = ... if param.required else None - fields[param.name] = ( - param_type, - Field(default=default, description=param_description), - ) - return create_model(f"{tool_def.name}Args", **fields) - except ValueError as e: - raise ValueError( - f"Error converting {tool_def.name} parameters into pydantic model for langchain: {e}" - ) - - -def process_tool_execution_response( - execute_response: ExecuteToolResponse, tool_name: str, langgraph: bool -) -> Any: - """Process the response from tool execution and handle errors appropriately. - - Args: - execute_response: The response from tool execution - tool_name: The name of the tool that was executed - langgraph: Whether LangGraph-specific behavior is enabled - - Returns: - The output value on success, or error details on failure - """ - if execute_response.success and execute_response.output is not None: - return execute_response.output.value - - # Extract detailed error information - error_details = { - "error": "Unknown error occurred", - "tool": tool_name, - } - - if ( - execute_response.output is not None - and execute_response.output.error is not None - ): - error = execute_response.output.error - error_message = ( - str(error.message) if hasattr(error, "message") else "Unknown error" - ) - error_details["error"] = error_message - - # Add all non-None optional error fields to the details - if ( - hasattr(error, "additional_prompt_content") - and error.additional_prompt_content is not None - ): - error_details["additional_prompt_content"] = error.additional_prompt_content - if hasattr(error, "can_retry") and error.can_retry is not None: - error_details["can_retry"] = str(error.can_retry) - if hasattr(error, "developer_message") and error.developer_message is not None: - error_details["developer_message"] = str(error.developer_message) - if hasattr(error, "retry_after_ms") and error.retry_after_ms is not None: - error_details["retry_after_ms"] = str(error.retry_after_ms) - - if langgraph: - raise NodeInterrupt(error_details) - return error_details - - -def create_tool_function( - client: Arcade, - tool_name: str, - tool_def: ToolDefinition, - args_schema: type[BaseModel], - langgraph: bool = False, -) -> Callable: - """Create a callable function to execute an Arcade tool. - - Args: - client: The Arcade client instance. - tool_name: The name of the tool to wrap. - tool_def: The ToolDefinition of the tool to wrap. - args_schema: The Pydantic model representing the tool's arguments. - langgraph: Whether to enable LangGraph-specific behavior. - - Returns: - A callable function that executes the tool. - """ - if langgraph and not LANGGRAPH_ENABLED: - raise ImportError( - "LangGraph is not installed. Please install it to use this feature." - ) - - requires_authorization = ( - tool_def.requirements is not None - and tool_def.requirements.authorization is not None - ) - - def tool_function(config: RunnableConfig, **kwargs: Any) -> Any: - """Execute the Arcade tool with the given parameters. - - Args: - config: RunnableConfig containing execution context. - **kwargs: Tool input arguments. - - Returns: - The output from the tool execution. - """ - user_id = config.get("configurable", {}).get("user_id") if config else None - - if requires_authorization: - if user_id is None: - error_message = f"user_id is required to run {tool_name}" - if langgraph: - raise NodeInterrupt(error_message) - return {"error": error_message} - - # Authorize the user for the tool - auth_response = client.tools.authorize(tool_name=tool_name, user_id=user_id) - if auth_response.status != "completed": - auth_message = ( - f"Please use the following link to authorize: {auth_response.url}" - ) - if langgraph: - raise NodeInterrupt(auth_message) - return {"error": auth_message} - - # Execute the tool with provided inputs - execute_response = client.tools.execute( - tool_name=tool_name, - input=kwargs, - user_id=user_id if user_id is not None else NOT_GIVEN, - ) - - return process_tool_execution_response(execute_response, tool_name, langgraph) - - return tool_function - - -def wrap_arcade_tool( - client: Union[Arcade, AsyncArcade], - tool_name: str, - tool_def: ToolDefinition, - langgraph: bool = False, -) -> StructuredTool: - """Wrap an Arcade `ToolDefinition` as a LangChain `StructuredTool`. - - Args: - client: The Arcade client instance. - tool_name: The name of the tool to wrap. - tool_def: The ToolDefinition object to wrap. - langgraph: Whether to enable LangGraph-specific behavior. - - Returns: - A StructuredTool instance representing the Arcade tool. - """ - description = tool_def.description or "No description provided." - - # Create a Pydantic model for the tool's input arguments - args_schema = tool_definition_to_pydantic_model(tool_def) - - # Create the action function - if isinstance(client, Arcade): - action_func = create_tool_function( - client=client, - tool_name=tool_name, - tool_def=tool_def, - args_schema=args_schema, - langgraph=langgraph, - ) - else: - # Use async tool function for AsyncArcade client - action_func = create_async_tool_function( - client=client, - tool_name=tool_name, - tool_def=tool_def, - args_schema=args_schema, - langgraph=langgraph, - ) - - # Create the StructuredTool instance - return StructuredTool.from_function( - func=action_func, - name=tool_name, - description=description, - args_schema=args_schema, - inject_kwargs={"user_id"}, - ) - - -def create_async_tool_function( - client: AsyncArcade, - tool_name: str, - tool_def: ToolDefinition, - args_schema: type[BaseModel], - langgraph: bool = False, -) -> Callable: - """Create an async callable function to execute an Arcade tool. - - Args: - client: The AsyncArcade client instance. - tool_name: The name of the tool to wrap. - tool_def: The ToolDefinition of the tool to wrap. - args_schema: The Pydantic model representing the tool's arguments. - langgraph: Whether to enable LangGraph-specific behavior. - - Returns: - An async callable function that executes the tool. - """ - if langgraph and not LANGGRAPH_ENABLED: - raise ImportError( - "LangGraph is not installed. Please install it to use this feature." - ) - - requires_authorization = ( - tool_def.requirements is not None - and tool_def.requirements.authorization is not None - ) - - async def tool_function(config: RunnableConfig, **kwargs: Any) -> Any: - """Run the Arcade tool with the given parameters. - - Args: - config: RunnableConfig containing execution context. - **kwargs: Tool input arguments. - - Returns: - The output from the tool execution. - """ - user_id = config.get("configurable", {}).get("user_id") if config else None - - if requires_authorization: - if user_id is None: - error_message = f"user_id is required to run {tool_name}" - if langgraph: - raise NodeInterrupt(error_message) - return {"error": error_message} - - # Authorize the user for the tool - auth_response = await client.tools.authorize( - tool_name=tool_name, user_id=user_id - ) - if auth_response.status != "completed": - auth_message = ( - f"Please use the following link to authorize: {auth_response.url}" - ) - if langgraph: - raise NodeInterrupt(auth_message) - return {"error": auth_message} - - # Execute the tool with provided inputs - execute_response = await client.tools.execute( - tool_name=tool_name, - input=kwargs, - user_id=user_id if user_id is not None else NOT_GIVEN, - ) - - return process_tool_execution_response(execute_response, tool_name, langgraph) - - return tool_function diff --git a/contrib/langchain/langchain_arcade/manager.py b/contrib/langchain/langchain_arcade/manager.py deleted file mode 100644 index 03a6997e..00000000 --- a/contrib/langchain/langchain_arcade/manager.py +++ /dev/null @@ -1,848 +0,0 @@ -import os -import warnings -from collections.abc import Iterator -from typing import Any, Optional, Union - -from arcadepy import NOT_GIVEN, Arcade, AsyncArcade -from arcadepy.types import ToolDefinition -from arcadepy.types.shared import AuthorizationResponse -from langchain_core.tools import StructuredTool - -from langchain_arcade._utilities import wrap_arcade_tool - -ClientType = Union[Arcade, AsyncArcade] - - -class LangChainToolManager: - """ - Base tool manager for LangChain framework. - Provides a common interface for both synchronous and asynchronous tool managers. - - This class handles the storage and retrieval of tool definitions and provides - common functionality used by both synchronous and asynchronous implementations. - """ - - def __init__(self) -> None: - self._tools: dict[str, ToolDefinition] = {} - - @property - def tools(self) -> list[str]: - """ - Get the list of tools by name in the manager. - - Returns: - A list of tool names (strings) currently stored in the manager. - """ - return list(self._tools.keys()) - - def __len__(self) -> int: - """Return the number of tools in the manager.""" - return len(self._tools) - - def _get_client_config(self, **kwargs: Any) -> dict[str, Any]: - """ - Get the client configurations from environment variables and kwargs. - - If api_key or base_url are in the kwargs, they will be used. - Otherwise, the environment variables ARCADE_API_KEY and ARCADE_BASE_URL will be used. - If both are provided, the kwargs will take precedence. - - Args: - **kwargs: Keyword arguments that may contain api_key and base_url. - - Returns: - A dictionary of client configuration parameters. - """ - client_kwargs = { - "api_key": kwargs.get("api_key", os.getenv("ARCADE_API_KEY")), - } - base_url = kwargs.get("base_url", os.getenv("ARCADE_BASE_URL")) - if base_url: - client_kwargs["base_url"] = base_url - return client_kwargs - - def _get_tool_definition(self, tool_name: str) -> ToolDefinition: - """ - Get a tool definition by name, raising an error if not found. - - Args: - tool_name: The name of the tool to retrieve. - - Returns: - The ToolDefinition for the specified tool. - - Raises: - ValueError: If the tool is not found in the manager. - """ - try: - return self._tools[tool_name] - except KeyError: - raise ValueError(f"Tool '{tool_name}' not found in this manager instance") - - def __getitem__(self, tool_name: str) -> ToolDefinition: - """ - Get a tool definition by name using dictionary-like access. - - Args: - tool_name: The name of the tool to retrieve. - - Returns: - The ToolDefinition for the specified tool. - - Raises: - ValueError: If the tool is not found in the manager. - """ - return self._get_tool_definition(tool_name) - - def requires_auth(self, tool_name: str) -> bool: - """ - Check if a tool requires authorization. - - Args: - tool_name: The name of the tool to check. - - Returns: - True if the tool requires authorization, False otherwise. - """ - tool_def = self._get_tool_definition(tool_name) - if tool_def.requirements is None: - return False - return tool_def.requirements.authorization is not None - - -class ToolManager(LangChainToolManager): - """ - Synchronous Arcade tool manager for LangChain framework. - - This class wraps Arcade tools as LangChain StructuredTool objects for integration - with synchronous operations. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> # Initialize with specific tools and toolkits - >>> manager.init_tools(tools=["Search.SearchGoogle"], toolkits=["Weather"]) - >>> # Get tools as LangChain StructuredTools - >>> langchain_tools = manager.to_langchain() - >>> # Handle authorization for tools that require it - >>> if manager.requires_auth("Search.SearchGoogle"): - >>> auth_response = manager.authorize("Search.SearchGoogle", "user_123") - >>> manager.wait_for_auth(auth_response.id) - """ - - def __init__(self, client: Optional[Arcade] = None, **kwargs: Any) -> None: - """ - Initialize the ToolManager. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> # or with an existing client - >>> client = Arcade(api_key="your-api-key") - >>> manager = ToolManager(client=client) - - Args: - client: Optional Arcade client instance. If not provided, one will be created. - **kwargs: Additional keyword arguments to pass to the Arcade client if creating one. - Common options include api_key and base_url. - """ - super().__init__() - if client is None: - client_kwargs = self._get_client_config(**kwargs) - client = Arcade(**client_kwargs) - self._client = client - - @property - def definitions(self) -> list[ToolDefinition]: - """ - Get the list of tool definitions in the manager. - - Returns: - A list of ToolDefinition objects currently stored in the manager. - """ - return list(self._tools.values()) - - def __iter__(self) -> Iterator[tuple[str, ToolDefinition]]: - """ - Iterate over the tools in the manager as (name, definition) pairs. - - Returns: - Iterator over (tool_name, tool_definition) tuples. - """ - yield from self._tools.items() - - def to_langchain( - self, use_interrupts: bool = True, use_underscores: bool = True - ) -> list[StructuredTool]: - """ - Get the tools in the manager as LangChain StructuredTool objects. - - Args: - use_interrupts: Whether to use interrupts for the tool. This is useful - for LangGraph workflows where you need to handle tool - authorization through state transitions. - use_underscores: Whether to use underscores for the tool name instead of periods. - For example, "Search_SearchGoogle" vs "Search.SearchGoogle". - Some model providers like OpenAI work better with underscores. - - Returns: - List of StructuredTool instances ready to use with LangChain. - """ - tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores) - return [ - wrap_arcade_tool( - self._client, tool_name, definition, langgraph=use_interrupts - ) - for tool_name, definition in tool_map.items() - ] - - def init_tools( - self, - tools: Optional[list[str]] = None, - toolkits: Optional[list[str]] = None, - limit: Optional[int] = None, - offset: Optional[int] = None, - raise_on_empty: bool = True, - ) -> list[StructuredTool]: - """ - Initialize the tools in the manager and return them as LangChain tools. - - This will clear any existing tools in the manager and replace them with - the new tools specified by the tools and toolkits parameters. - - Note: In version 2.0+, this method returns a list of StructuredTool objects. - In earlier versions, it returned None. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> langchain_tools = manager.init_tools(tools=["Search.SearchGoogle"]) - >>> # Use these tools with a LangChain chain or agent - >>> agent = Agent(tools=langchain_tools, llm=llm) - - Args: - tools: Optional list of specific tool names to include (e.g., "Search.SearchGoogle"). - toolkits: Optional list of toolkit names to include all tools from (e.g., "Search"). - limit: Optional limit on the number of tools to retrieve per request. - offset: Optional offset for paginated requests. - raise_on_empty: Whether to raise an error if no tools or toolkits are provided. - - Returns: - List of StructuredTool instances ready to use with LangChain. - - Raises: - ValueError: If no tools or toolkits are provided and raise_on_empty is True. - """ - tools_list = self._retrieve_tool_definitions( - tools, toolkits, raise_on_empty, limit, offset - ) - self._tools = _create_tool_map(tools_list) - return self.to_langchain() - - def authorize(self, tool_name: str, user_id: str) -> AuthorizationResponse: - """ - Authorize a user for a specific tool. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> manager.init_tools(tools=["Gmail.SendEmail"]) - >>> auth_response = manager.authorize("Gmail.SendEmail", "user_123") - >>> # auth_response.auth_url contains the URL for the user to authorize - - Args: - tool_name: The name of the tool to authorize. - user_id: The user ID to authorize. This should be a unique identifier for the user. - - Returns: - AuthorizationResponse containing authorization details, including the auth_url - that should be presented to the user to complete authorization. - """ - return self._client.tools.authorize(tool_name=tool_name, user_id=user_id) - - def is_authorized(self, authorization_id: str) -> bool: - """ - Check if a tool authorization is complete. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> auth_response = manager.authorize("Gmail.SendEmail", "user_123") - >>> # After user completes authorization - >>> is_complete = manager.is_authorized(auth_response.id) - - Args: - authorization_id: The authorization ID to check. This can be the full AuthorizationResponse - object or just the ID string. - - Returns: - True if the authorization is completed, False otherwise. - """ - # Handle case where entire AuthorizationResponse object is passed - if hasattr(authorization_id, "id"): - authorization_id = authorization_id.id - - response = self._client.auth.status(id=authorization_id) - if response: - return response.status == "completed" - return False - - def wait_for_auth(self, authorization_id: str) -> AuthorizationResponse: - """ - Wait for a tool authorization to complete. This method blocks until - the authorization is complete or fails. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> auth_response = manager.authorize("Gmail.SendEmail", "user_123") - >>> # Share auth_response.auth_url with the user - >>> # Wait for the user to complete authorization - >>> completed_auth = manager.wait_for_auth(auth_response.id) - - Args: - authorization_id: The authorization ID to wait for. This can be the full - AuthorizationResponse object or just the ID string. - - Returns: - AuthorizationResponse with the completed authorization details. - """ - # Handle case where entire AuthorizationResponse object is passed - if hasattr(authorization_id, "id"): - authorization_id = authorization_id.id - - return self._client.auth.wait_for_completion(authorization_id) - - def _retrieve_tool_definitions( - self, - tools: Optional[list[str]] = None, - toolkits: Optional[list[str]] = None, - raise_on_empty: bool = True, - limit: Optional[int] = None, - offset: Optional[int] = None, - ) -> list[ToolDefinition]: - """ - Retrieve tool definitions from the Arcade client, accounting for pagination. - - Args: - tools: Optional list of specific tool names to include. - toolkits: Optional list of toolkit names to include all tools from. - raise_on_empty: Whether to raise an error if no tools or toolkits are provided. - limit: Optional limit on the number of tools to retrieve per request. - offset: Optional offset for paginated requests. - - Returns: - List of ToolDefinition instances. - - Raises: - ValueError: If no tools or toolkits are provided and raise_on_empty is True. - """ - all_tools: list[ToolDefinition] = [] - - # If no specific tools or toolkits are requested, raise an error. - if not tools and not toolkits: - if raise_on_empty: - raise ValueError( - "No tools or toolkits provided to retrieve tool definitions." - ) - return [] - - # Retrieve individual tools if specified - if tools: - for tool_id in tools: - single_tool = self._client.tools.get(name=tool_id) - all_tools.append(single_tool) - - # Retrieve tools from specified toolkits - if toolkits: - for tk in toolkits: - # Convert None to NOT_GIVEN for Stainless client - paginated_tools = self._client.tools.list( - toolkit=tk, - limit=limit if limit is not None else NOT_GIVEN, - offset=offset if offset is not None else NOT_GIVEN, - ) - all_tools.extend(paginated_tools) - - return all_tools - - def add_tool(self, tool_name: str) -> None: - """ - Add a single tool to the manager by name. - - Unlike init_tools(), this method preserves existing tools in the manager - and only adds the specified tool. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> manager.add_tool("Gmail.SendEmail") - >>> manager.add_tool("Search.SearchGoogle") - >>> # Get all tools including newly added ones - >>> all_tools = manager.to_langchain() - - Args: - tool_name: The fully qualified name of the tool to add (e.g., "Search.SearchGoogle") - - Raises: - ValueError: If the tool cannot be found - """ - tool = self._client.tools.get(name=tool_name) - self._tools.update(_create_tool_map([tool])) - - def add_toolkit( - self, - toolkit_name: str, - limit: Optional[int] = None, - offset: Optional[int] = None, - ) -> None: - """ - Add all tools from a specific toolkit to the manager. - - Unlike init_tools(), this method preserves existing tools in the manager - and only adds the tools from the specified toolkit. - - Example: - >>> manager = ToolManager(api_key="your-api-key") - >>> manager.add_toolkit("Gmail") - >>> manager.add_toolkit("Search") - >>> # Get all tools including newly added ones - >>> all_tools = manager.to_langchain() - - Args: - toolkit_name: The name of the toolkit to add (e.g., "Search") - limit: Optional limit on the number of tools to retrieve per request - offset: Optional offset for paginated requests - - Raises: - ValueError: If the toolkit cannot be found or has no tools - """ - tools = self._client.tools.list( - toolkit=toolkit_name, - limit=NOT_GIVEN if limit is None else limit, - offset=NOT_GIVEN if offset is None else offset, - ) - - for tool in tools: - self._tools.update(_create_tool_map([tool])) - - def get_tools( - self, - tools: Optional[list[str]] = None, - toolkits: Optional[list[str]] = None, - langgraph: bool = True, - ) -> list[StructuredTool]: - """ - DEPRECATED: Return the tools in the manager as LangChain StructuredTool objects. - - This method is deprecated and will be removed in a future major version. - Please use `init_tools()` to initialize tools and `to_langchain()` to convert them. - - Args: - tools: Optional list of tool names to include. - toolkits: Optional list of toolkits to include. - langgraph: Whether to use LangGraph-specific behavior - such as NodeInterrupts for auth. - - Returns: - List of StructuredTool instances. - """ - warnings.warn( - "get_tools() is deprecated and will be removed in the next major version. " - "Please use init_tools() to initialize tools and to_langchain() to convert them.", - DeprecationWarning, - stacklevel=2, - ) - - # Support existing usage pattern - if tools or toolkits: - self.init_tools(tools=tools, toolkits=toolkits) - - return self.to_langchain(use_interrupts=langgraph) - - -class ArcadeToolManager(ToolManager): - """ - Deprecated alias for ToolManager. - - ArcadeToolManager is deprecated and will be removed in the next major version. - Please use ToolManager instead. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - warnings.warn( - "ArcadeToolManager is deprecated and will be removed in the next major version. " - "Please use ToolManager instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) - - -class AsyncToolManager(LangChainToolManager): - """ - Async version of Arcade tool manager for LangChain framework. - - This class wraps Arcade tools as LangChain StructuredTool objects for integration - with asynchronous operations. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> # Initialize with specific tools and toolkits - >>> await manager.init_tools(tools=["Search.SearchGoogle"], toolkits=["Weather"]) - >>> # Get tools as LangChain StructuredTools - >>> langchain_tools = await manager.to_langchain() - >>> # Handle authorization for tools that require it - >>> if manager.requires_auth("Search.SearchGoogle"): - >>> auth_response = await manager.authorize("Search.SearchGoogle", "user_123") - >>> await manager.wait_for_auth(auth_response.id) - """ - - def __init__( - self, - client: Optional[AsyncArcade] = None, - **kwargs: Any, - ) -> None: - """ - Initialize the AsyncToolManager. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> # or with an existing client - >>> client = AsyncArcade(api_key="your-api-key") - >>> manager = AsyncToolManager(client=client) - - Args: - client: Optional AsyncArcade client instance. If not provided, one will be created. - **kwargs: Additional keyword arguments to pass to the AsyncArcade client if creating one. - Common options include api_key and base_url. - """ - super().__init__() - if not client: - client_kwargs = self._get_client_config(**kwargs) - client = AsyncArcade(**client_kwargs) - self._client = client - - @property - def definitions(self) -> list[ToolDefinition]: - """ - Get the list of tool definitions in the manager. - - Returns: - A list of ToolDefinition objects currently stored in the manager. - """ - return list(self._tools.values()) - - def __iter__(self) -> Iterator[tuple[str, ToolDefinition]]: - """ - Iterate over the tools in the manager as (name, definition) pairs. - - Returns: - Iterator over (tool_name, tool_definition) tuples. - """ - yield from self._tools.items() - - async def init_tools( - self, - tools: Optional[list[str]] = None, - toolkits: Optional[list[str]] = None, - limit: Optional[int] = None, - offset: Optional[int] = None, - raise_on_empty: bool = True, - ) -> list[StructuredTool]: - """ - Initialize the tools in the manager asynchronously and return them as LangChain tools. - - This will clear any existing tools in the manager and replace them with - the new tools specified by the tools and toolkits parameters. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> langchain_tools = await manager.init_tools(tools=["Search.SearchGoogle"]) - >>> # Use these tools with a LangChain chain or agent - >>> agent = Agent(tools=langchain_tools, llm=llm) - - Args: - tools: Optional list of specific tool names to include (e.g., "Search.SearchGoogle"). - toolkits: Optional list of toolkit names to include all tools from (e.g., "Search"). - limit: Optional limit on the number of tools to retrieve per request. - offset: Optional offset for paginated requests. - raise_on_empty: Whether to raise an error if no tools or toolkits are provided. - - Returns: - List of StructuredTool instances ready to use with LangChain. - - Raises: - ValueError: If no tools or toolkits are provided and raise_on_empty is True. - """ - tools_list = await self._retrieve_tool_definitions( - tools, toolkits, raise_on_empty, limit, offset - ) - self._tools.update(_create_tool_map(tools_list)) - return await self.to_langchain() - - async def to_langchain( - self, use_interrupts: bool = True, use_underscores: bool = True - ) -> list[StructuredTool]: - """ - Get the tools in the manager as LangChain StructuredTool objects asynchronously. - - Args: - use_interrupts: Whether to use interrupts for the tool. This is useful - for LangGraph workflows where you need to handle tool - authorization through state transitions. - use_underscores: Whether to use underscores for the tool name instead of periods. - For example, "Search_SearchGoogle" vs "Search.SearchGoogle". - Some model providers like OpenAI work better with underscores. - - Returns: - List of StructuredTool instances ready to use with LangChain. - """ - tool_map = _create_tool_map(self.definitions, use_underscores=use_underscores) - return [ - wrap_arcade_tool( - self._client, tool_name, definition, langgraph=use_interrupts - ) - for tool_name, definition in tool_map.items() - ] - - async def authorize(self, tool_name: str, user_id: str) -> AuthorizationResponse: - """ - Authorize a user for a tool. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> await manager.init_tools(tools=["Gmail.SendEmail"]) - >>> auth_response = await manager.authorize("Gmail.SendEmail", "user_123") - >>> # auth_response.auth_url contains the URL for the user to authorize - - Args: - tool_name: The name of the tool to authorize. - user_id: The user ID to authorize. This should be a unique identifier for the user. - - Returns: - AuthorizationResponse containing authorization details, including the auth_url - that should be presented to the user to complete authorization. - """ - return await self._client.tools.authorize(tool_name=tool_name, user_id=user_id) - - async def is_authorized(self, authorization_id: str) -> bool: - """ - Check if a tool authorization is complete. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> auth_response = await manager.authorize("Gmail.SendEmail", "user_123") - >>> # After user completes authorization - >>> is_complete = await manager.is_authorized(auth_response.id) - - Args: - authorization_id: The authorization ID to check. This can be the full AuthorizationResponse - object or just the ID string. - - Returns: - True if the authorization is completed, False otherwise. - """ - # Handle case where entire AuthorizationResponse object is passed - if hasattr(authorization_id, "id"): - authorization_id = authorization_id.id - - auth_status = await self._client.auth.status(id=authorization_id) - return auth_status.status == "completed" - - async def wait_for_auth(self, authorization_id: str) -> AuthorizationResponse: - """ - Wait for a tool authorization to complete. This method blocks until - the authorization is complete or fails. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> auth_response = await manager.authorize("Gmail.SendEmail", "user_123") - >>> # Share auth_response.auth_url with the user - >>> # Wait for the user to complete authorization - >>> completed_auth = await manager.wait_for_auth(auth_response.id) - - Args: - authorization_id: The authorization ID to wait for. This can be the full - AuthorizationResponse object or just the ID string. - - Returns: - AuthorizationResponse with the completed authorization details. - """ - # Handle case where entire AuthorizationResponse object is passed - if hasattr(authorization_id, "id"): - authorization_id = authorization_id.id - - return await self._client.auth.wait_for_completion(authorization_id) - - async def _retrieve_tool_definitions( - self, - tools: Optional[list[str]] = None, - toolkits: Optional[list[str]] = None, - raise_on_empty: bool = True, - limit: Optional[int] = None, - offset: Optional[int] = None, - ) -> list[ToolDefinition]: - """ - Retrieve tool definitions asynchronously from the Arcade client, accounting for pagination. - - Args: - tools: Optional list of specific tool names to include. - toolkits: Optional list of toolkit names to include all tools from. - raise_on_empty: Whether to raise an error if no tools or toolkits are provided. - limit: Optional limit on the number of tools to retrieve per request. - offset: Optional offset for paginated requests. - - Returns: - List of ToolDefinition instances. - - Raises: - ValueError: If no tools or toolkits are provided and raise_on_empty is True. - """ - all_tools: list[ToolDefinition] = [] - - # If no specific tools or toolkits are requested, raise an error. - if not tools and not toolkits: - if raise_on_empty: - raise ValueError( - "No tools or toolkits provided to retrieve tool definitions." - ) - return [] - - # First, gather single tools if the user specifically requested them. - if tools: - for tool_id in tools: - # ToolsResource.get(...) returns a single ToolDefinition. - single_tool = await self._client.tools.get(name=tool_id) - all_tools.append(single_tool) - - # Next, gather tool definitions from any requested toolkits. - if toolkits: - for tk in toolkits: - # Convert None to NOT_GIVEN for Stainless client - paginated_tools = await self._client.tools.list( - toolkit=tk, - limit=NOT_GIVEN if limit is None else limit, - offset=NOT_GIVEN if offset is None else offset, - ) - async for tool in paginated_tools: - all_tools.append(tool) - - return all_tools - - async def add_tool(self, tool_name: str) -> None: - """ - Add a single tool to the manager by name. - - Unlike init_tools(), this method preserves existing tools in the manager - and only adds the specified tool. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> await manager.add_tool("Gmail.SendEmail") - >>> await manager.add_tool("Search.SearchGoogle") - >>> # Get all tools including newly added ones - >>> all_tools = await manager.to_langchain() - - Args: - tool_name: The fully qualified name of the tool to add (e.g., "Search.SearchGoogle") - - Raises: - ValueError: If the tool cannot be found - """ - tool = await self._client.tools.get(name=tool_name) - self._tools.update(_create_tool_map([tool])) - - async def add_toolkit( - self, - toolkit_name: str, - limit: Optional[int] = None, - offset: Optional[int] = None, - ) -> None: - """ - Add all tools from a specific toolkit to the manager. - - Unlike init_tools(), this method preserves existing tools in the manager - and only adds the tools from the specified toolkit. - - Example: - >>> manager = AsyncToolManager(api_key="your-api-key") - >>> await manager.add_toolkit("Gmail") - >>> await manager.add_toolkit("Search") - >>> # Get all tools including newly added ones - >>> all_tools = await manager.to_langchain() - - Args: - toolkit_name: The name of the toolkit to add (e.g., "Search") - limit: Optional limit on the number of tools to retrieve per request - offset: Optional offset for paginated requests - - Raises: - ValueError: If the toolkit cannot be found or has no tools - """ - paginated_tools = await self._client.tools.list( - toolkit=toolkit_name, - limit=NOT_GIVEN if limit is None else limit, - offset=NOT_GIVEN if offset is None else offset, - ) - - async for tool in paginated_tools: - self._tools.update(_create_tool_map([tool])) - - async def get_tools( - self, - tools: Optional[list[str]] = None, - toolkits: Optional[list[str]] = None, - langgraph: bool = True, - ) -> list[StructuredTool]: - """ - DEPRECATED: Return the tools in the manager as LangChain StructuredTool objects. - - This method is deprecated and will be removed in a future major version. - Please use `init_tools()` to initialize tools and `to_langchain()` to convert them. - - Args: - tools: Optional list of tool names to include. - toolkits: Optional list of toolkits to include. - langgraph: Whether to use LangGraph-specific behavior - such as NodeInterrupts for auth. - - Returns: - List of StructuredTool instances. - """ - warnings.warn( - "get_tools() is deprecated and will be removed in the next major version. " - "Please use init_tools() to initialize tools and to_langchain() to convert them.", - DeprecationWarning, - stacklevel=2, - ) - - # Support existing usage pattern - if tools or toolkits: - return await self.init_tools(tools=tools, toolkits=toolkits) - return [] - - -def _create_tool_map( - tools: list[ToolDefinition], - use_underscores: bool = True, -) -> dict[str, ToolDefinition]: - """ - Build a dictionary that maps the "full_tool_name" to the tool definition. - - Args: - tools: List of ToolDefinition objects to map. - use_underscores: Whether to use underscores instead of periods in tool names. - For example, "Search_SearchGoogle" vs "Search.SearchGoogle". - - Returns: - Dictionary mapping tool names to tool definitions. - - Note: - This is a temporary solution to support the naming convention of certain model providers - like OpenAI, which work better with underscores in tool names. - """ - tool_map: dict[str, ToolDefinition] = {} - for tool in tools: - # Ensure toolkit name and tool name are not None before creating the key - toolkit_name = tool.toolkit.name if tool.toolkit and tool.toolkit.name else None - if toolkit_name and tool.name: - if use_underscores: - tool_name = f"{toolkit_name}_{tool.name}" - else: - tool_name = f"{toolkit_name}.{tool.name}" - tool_map[tool_name] = tool - return tool_map diff --git a/contrib/langchain/langchain_arcade/py.typed b/contrib/langchain/langchain_arcade/py.typed deleted file mode 100644 index e69de29b..00000000 diff --git a/contrib/langchain/pyproject.toml b/contrib/langchain/pyproject.toml deleted file mode 100644 index 89ab23d3..00000000 --- a/contrib/langchain/pyproject.toml +++ /dev/null @@ -1,59 +0,0 @@ -[build-system] -requires = [ "hatchling",] -build-backend = "hatchling.build" - -[project] -name = "langchain-arcade" -version = "1.4.5" -description = "An integration package connecting Arcade and Langchain/LangGraph" -readme = "README.md" -repository = "https://github.com/arcadeai/arcade-mcp/tree/main/contrib/langchain" -license = "MIT" -requires-python = ">=3.10" -dependencies = [ - "arcadepy>=1.7.0", - "langchain-core>=0.3.80,<0.4", -] - - -[project.optional-dependencies] -dev = [ - "pytest>=8.3.0,<8.4.0", - "pytest-cov>=4.0.0,<4.1.0", - "pytest-mock>=3.11.1,<3.12.0", - "pytest-asyncio>=0.24.0,<0.25.0", - "mypy>=1.5.1,<1.6.0", - "pre-commit>=3.4.0,<3.5.0", - "ruff>=0.7.4,<0.8.0", - "langgraph>=0.3.23,<0.4" -] - - -[tool.mypy] -files = ["langchain_arcade"] -python_version = "3.10" -disallow_untyped_defs = "True" -disallow_any_unimported = "True" -no_implicit_optional = "True" -check_untyped_defs = "True" -warn_return_any = "True" -warn_unused_ignores = "True" -show_error_codes = "True" -ignore_missing_imports = "True" - -[tool.pytest.ini_options] -testpaths = ["tests"] - - -[tool.coverage.run] -branch = true -source = ["langchain_arcade"] - -[tool.coverage.report] -skip_empty = true - -[tool.ruff.lint] -ignore = ["C901"] - -[tool.hatch.build.targets.wheel] -packages = [ "langchain_arcade",] diff --git a/contrib/langchain/tests/conftest.py b/contrib/langchain/tests/conftest.py deleted file mode 100644 index 43d0fdb6..00000000 --- a/contrib/langchain/tests/conftest.py +++ /dev/null @@ -1,33 +0,0 @@ -import os - -import pytest -from arcadepy import Arcade - - -@pytest.fixture(scope="session") -def arcade_base_url(): - """ - Retrieve the ARCADE_BASE_URL from the environment, falling back to a default - if not found. - """ - return os.getenv("ARCADE_BASE_URL", "http://localhost:9099") - - -@pytest.fixture(scope="session") -def arcade_api_key(): - """ - Retrieve the ARCADE_API_KEY from the environment, falling back to a default - if not found. - """ - return os.getenv("ARCADE_API_KEY", "test_api_key") - - -@pytest.fixture(scope="session") -def arcade_client(arcade_base_url, arcade_api_key): - """ - Creates a single Arcade client instance for use in all tests. - Any method calls on this client can be patched/mocked within the tests. - """ - client = Arcade(api_key=arcade_api_key, base_url=arcade_base_url) - yield client - # Teardown logic would go here if necessary diff --git a/contrib/langchain/tests/test_manager.py b/contrib/langchain/tests/test_manager.py deleted file mode 100644 index 2aa19879..00000000 --- a/contrib/langchain/tests/test_manager.py +++ /dev/null @@ -1,738 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from arcadepy import NOT_GIVEN -from arcadepy.pagination import AsyncOffsetPage, SyncOffsetPage -from arcadepy.types import ToolDefinition -from arcadepy.types.shared import AuthorizationResponse -from langchain_arcade.manager import ArcadeToolManager, AsyncToolManager, ToolManager - - -@pytest.fixture -def mock_arcade_client(): - """ - A fixture to mock the Arcade client object for testing the ToolManager. - - This mocks all relevant methods used by the manager, including: - - tools.get - - tools.list - - tools.authorize - - auth.status - - auth.wait_for_completion - """ - mock_client = MagicMock() - # Mock the "tools" sub-client - mock_client.tools.get = MagicMock() - mock_client.tools.list = MagicMock() - mock_client.tools.authorize = MagicMock() - # Mock the "auth" sub-client - mock_client.auth.status = MagicMock() - mock_client.auth.wait_for_completion = MagicMock() - - return mock_client - - -@pytest.fixture -def async_mock_arcade_client(): - """ - A fixture to mock the Arcade client object for testing the AsyncToolManager. - """ - mock_client = AsyncMock() - mock_client.tools.get = AsyncMock() - mock_client.tools.list = AsyncMock() - mock_client.tools.authorize = AsyncMock() - mock_client.auth.status = AsyncMock() - mock_client.auth.wait_for_completion = AsyncMock() - return mock_client - - -@pytest.fixture -def manager(mock_arcade_client): - """ - A fixture that creates a ToolManager with the mocked Arcade client. - """ - return ToolManager(client=mock_arcade_client) - - -@pytest.fixture -def async_manager(async_mock_arcade_client): - """ - A fixture that creates an AsyncToolManager with the mocked Arcade client. - """ - return AsyncToolManager(client=async_mock_arcade_client) - - -@pytest.fixture(params=[("sync", False), ("async", True)]) -def manager_fixture(request, manager, async_manager): - """ - A parameterized fixture that returns a tuple with: - - The appropriate manager (sync or async) - - A boolean indicating if it's async - - The appropriate mock client - """ - param_name, is_async = request.param - if is_async: - return async_manager, True - else: - return manager, False - - -@pytest.fixture -def make_tool(): - """ - A factory fixture for creating a valid ToolDefinition with a given - fully qualified name. Because the underlying ToolDefinition model - expects "toolkit" to be a dictionary with at least one field (for example "slug"), - and "requirements.authorization" to be a valid dictionary if present, we set them up - accordingly. - """ - - def _make_tool(fully_qualified_name="GoogleSearch_Search", **kwargs): - # Split on the first dot to derive a 'toolkit' slug and a tool 'name' - if "." in fully_qualified_name: - raw_toolkit, raw_tool_name = fully_qualified_name.split(".", 1) - elif "_" in fully_qualified_name: - # Convert from "_" to "." to match the expected format of tool name when - # using Langchain models for LLM inference. - raw_toolkit, raw_tool_name = fully_qualified_name.split("_", 1) - - else: - raw_toolkit, raw_tool_name = fully_qualified_name, fully_qualified_name - - # Provide a default toolkit dict unless one already exists in kwargs - toolkit = kwargs.pop("toolkit", {"name": raw_toolkit}) - - # Provide a default input - # arcadepy.types.ToolDefinition expects "input" to be a valid structure (dict). - tool_input = kwargs.pop("input", {"parameters": []}) - - # Convert MagicMock-based requirements (with authorization) to an appropriate dict, - # or use what's passed. If none is passed, default to None. - requirements = kwargs.pop("requirements", None) - if requirements is not None and not isinstance(requirements, dict): - # If it's e.g. a MagicMock(authorization="xyz"), convert it to a dict - req_auth = getattr(requirements, "authorization", None) - # If the test expects an authorization presence, represent it as a dict - # that Pydantic can parse - if req_auth is not None: - requirements = {"authorization": {"type": req_auth}} - else: - requirements = {"authorization": None} - - # Provide a default description if none is supplied - description = kwargs.pop("description", "Mock tool for testing") - - # Build the pydantic fields - data = { - "fully_qualified_name": fully_qualified_name, - "qualified_name": fully_qualified_name, - "name": raw_tool_name, - "toolkit": toolkit, - "input": tool_input, - "description": description, - "requirements": requirements, - } - data.update(kwargs) # merge any extras - - return ToolDefinition(**data) - - return _make_tool - - -async def maybe_await(obj, is_async): - """Helper to handle both sync and async return values""" - if is_async: - return await obj - return obj - - -@pytest.mark.asyncio -async def test_init_tools_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that init_tools clears any existing tools and retrieves new ones - from either an explicit list of tools or an entire toolkit. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - mock_tool = make_tool("GoogleSearch_Search") - client.tools.get.return_value = mock_tool - - page_cls = AsyncOffsetPage if is_async else SyncOffsetPage - client.tools.list.return_value = page_cls(items=[mock_tool]) - - # Act - result = await maybe_await( - manager.init_tools(tools=["GoogleSearch_Search"]), is_async - ) - - # Assert - assert "GoogleSearch_Search" in manager.tools - assert manager._tools["GoogleSearch_Search"] == mock_tool - client.tools.get.assert_called_once_with(name="GoogleSearch_Search") - # Verify the result is a list of StructuredTool objects - assert len(result) == 1 - - -@pytest.mark.asyncio -async def test_to_langchain_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that to_langchain returns the tools as StructuredTool objects. - """ - # Arrange - manager, is_async = manager_fixture - - mock_tool = make_tool("GoogleSearch_Search") - manager._tools = {"GoogleSearch_Search": mock_tool} - - # Act - with default parameters - result = await maybe_await(manager.to_langchain(), is_async) - - # Assert - assert len(result) == 1 - assert result[0].name == "GoogleSearch_Search" - - # Act - with underscores=False - result = await maybe_await(manager.to_langchain(use_underscores=False), is_async) - - # Assert - assert len(result) == 1 - assert result[0].name == "GoogleSearch.Search" - - -@pytest.mark.asyncio -async def test_deprecated_get_tools_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that the deprecated get_tools method still works but issues a warning. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - mock_tool = make_tool("GoogleSearch_Search") - client.tools.get.return_value = mock_tool - manager._tools = {} # Ensure no tools are already loaded - - # Act - Check for deprecation warning - with pytest.warns(DeprecationWarning): - result = await maybe_await( - manager.get_tools(tools=["GoogleSearch_Search"]), is_async - ) - - # Assert - Method should still work - assert len(result) == 1 - assert "GoogleSearch_Search" in manager.tools - client.tools.get.assert_called_once_with(name="GoogleSearch_Search") - - -@pytest.mark.asyncio -async def test_add_tool_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that add_tool adds a single tool to the manager without clearing existing tools. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - # Set up two different mock tools - mock_tool_google = make_tool("GoogleSearch_Search") - mock_tool_bing = make_tool("BingSearch_Search") - - # First tool already exists in manager - manager._tools = {"GoogleSearch_Search": mock_tool_google} - - # Second tool will be added - client.tools.get.return_value = mock_tool_bing - - # Act - await maybe_await(manager.add_tool("BingSearch_Search"), is_async) - - # Assert - Both tools should now be in the manager - assert "GoogleSearch_Search" in manager.tools - assert "BingSearch_Search" in manager.tools - assert len(manager.tools) == 2 - client.tools.get.assert_called_once_with(name="BingSearch_Search") - - -@pytest.mark.asyncio -async def test_add_toolkit_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that add_toolkit adds all tools from a toolkit without clearing existing tools. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - # Create a tool that's already in the manager - mock_tool_send_email = make_tool("Gmail_SendEmail") - manager._tools = {"Gmail_SendEmail": mock_tool_send_email} - - # Create tools to be added from the toolkit - mock_tool_list_emails = make_tool("Gmail_ListEmails") - mock_tool_trash_email = make_tool("Gmail_TrashEmail") - - # Mock the response for toolkit listing - page_cls = AsyncOffsetPage if is_async else SyncOffsetPage - client.tools.list.return_value = page_cls( - items=[mock_tool_list_emails, mock_tool_trash_email] - ) - - # Act - await maybe_await(manager.add_toolkit("Search"), is_async) - - # Assert - All tools should now be in the manager - assert len(manager.tools) == 3 - assert "Gmail_SendEmail" in manager.tools - assert "Gmail_ListEmails" in manager.tools - assert "Gmail_TrashEmail" in manager.tools - client.tools.list.assert_called_once_with( - toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN - ) - - -@pytest.mark.asyncio -async def test_is_authorized_with_response_object_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client -): - """ - Test the is_authorized method accepting both authorization ID string and AuthorizationResponse. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - mock_type = AsyncMock if is_async else MagicMock - client.auth.status.return_value = mock_type(status="completed") - - # Create an auth response object - auth_response = AuthorizationResponse( - id="auth_abc", status="pending", tool_fully_qualified_name="GoogleSearch_Search" - ) - - # Act - Test with string ID - status_result1 = await maybe_await(manager.is_authorized("auth_abc"), is_async) - - # Act - Test with response object - status_result2 = await maybe_await(manager.is_authorized(auth_response), is_async) - - # Assert - assert status_result1 is True - assert status_result2 is True - client.auth.status.assert_any_call(id="auth_abc") - client.auth.status.assert_any_call( - id="auth_abc" - ) # Should be called with the same ID both times - - -@pytest.mark.asyncio -async def test_wait_for_auth_with_response_object_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client -): - """ - Test the wait_for_auth method accepting both authorization ID string and AuthorizationResponse. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - completed_response = AuthorizationResponse( - id="auth_abc", - status="completed", - tool_fully_qualified_name="GoogleSearch_Search", - ) - client.auth.wait_for_completion.return_value = completed_response - - # Create an auth response object - auth_response = AuthorizationResponse( - id="auth_abc", status="pending", tool_fully_qualified_name="GoogleSearch_Search" - ) - - # Act - Test with string ID - result1 = await maybe_await(manager.wait_for_auth("auth_abc"), is_async) - - # Act - Test with response object - result2 = await maybe_await(manager.wait_for_auth(auth_response), is_async) - - # Assert - assert result1 == completed_response - assert result2 == completed_response - client.auth.wait_for_completion.assert_any_call("auth_abc") - client.auth.wait_for_completion.assert_any_call( - "auth_abc" - ) # Should be called with the same ID both times - - -@pytest.mark.asyncio -async def test_get_tools_no_init_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that the deprecated get_tools method without previous initialization - issues a warning and fetches tools. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - mock_tool = make_tool("GoogleSearch_Search") - page_cls = AsyncOffsetPage if is_async else SyncOffsetPage - client.tools.list.return_value = page_cls(items=[mock_tool]) - - # Act - Check for deprecation warning - with pytest.warns(DeprecationWarning): - tools = await maybe_await( - manager.get_tools(), is_async - ) # No param means manager calls list - - # Assert - assert len(tools) == 0 - assert "GoogleSearch_Search" not in manager.tools - - -@pytest.mark.asyncio -async def test_get_tools_with_explicit_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that the deprecated get_tools method with explicitly specified tools - issues a warning and fetches the requested tools. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - mock_tool_google = make_tool("GoogleSearch_Search") - mock_tool_bing = make_tool("BingSearch_Search") - client.tools.get.side_effect = [mock_tool_google, mock_tool_bing] - - # Act - Check for deprecation warning - with pytest.warns(DeprecationWarning): - retrieved_tools = await maybe_await( - manager.get_tools(tools=["GoogleSearch_Search", "BingSearch_Search"]), - is_async, - ) - - # Assert - assert len(retrieved_tools) == 2 - assert set(manager.tools) == {"GoogleSearch_Search", "BingSearch_Search"} - client.tools.get.assert_any_call(name="GoogleSearch_Search") - client.tools.get.assert_any_call(name="BingSearch_Search") - - -def test_arcade_tool_manager_deprecation_warning(): - """ - Test that the ArcadeToolManager class issues a deprecation warning. - """ - # Act - Check for deprecation warning - with pytest.warns(DeprecationWarning) as warnings_record: - ArcadeToolManager(client=MagicMock()) - # Assert - assert any( - "ArcadeToolManager is deprecated" in str(w.message) for w in warnings_record - ) - - -@pytest.mark.asyncio -async def test_authorize_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client -): - """ - Test the authorize method to ensure it calls the Arcade client's - tools.authorize method correctly. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - auth_response = AuthorizationResponse( - id="auth_123", status="pending", tool_fully_qualified_name="GoogleSearch_Search" - ) - client.tools.authorize.return_value = auth_response - - # Act - response = await maybe_await( - manager.authorize(tool_name="GoogleSearch_Search", user_id="user_123"), is_async - ) - - # Assert - assert response.id == "auth_123" - assert response.status == "pending" - client.tools.authorize.assert_called_once_with( - tool_name="GoogleSearch_Search", user_id="user_123" - ) - - -def test_requires_auth_true(manager, make_tool): - """ - Test the requires_auth method returning True if - the stored tool definition's requirements contain an authorization entry. - """ - # Arrange - tool_name = "GoogleSearch_Search" - # Pass a MagicMock with 'authorization' to ensure it gets converted - mock_tool_def = make_tool( - tool_name, requirements=MagicMock(authorization="some_required_auth") - ) - manager._tools[tool_name] = mock_tool_def - - # Act - result = manager.requires_auth(tool_name) - - # Assert - assert result is True - - -def test_requires_auth_false(manager, make_tool): - """ - Test the requires_auth method returning False if authorization - is not required in the tool definition. - """ - # Arrange - tool_name = "GoogleSearch_Search" - mock_tool_def = make_tool(tool_name, requirements=MagicMock(authorization=None)) - manager._tools[tool_name] = mock_tool_def - - # Act - result = manager.requires_auth(tool_name) - - # Assert - assert result is False - - -def test_get_tool_definition_existing(manager, make_tool): - """ - Test the internal _get_tool_definition method retrieving - an existing tool definition by name. - """ - # Arrange - tool_name = "GoogleSearch_Search" - mock_tool_def = make_tool(tool_name) - manager._tools[tool_name] = mock_tool_def - - # Act - definition = manager._get_tool_definition(tool_name) - - # Assert - assert definition == mock_tool_def - - -def test_get_tool_definition_missing(manager): - """ - Test the internal _get_tool_definition method raising a ValueError - if the tool is not in the manager. - """ - # Act & Assert - with pytest.raises(ValueError) as excinfo: - manager._get_tool_definition("Nonexistent.Tool") - - assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value) - - -def test_retrieve_tool_definitions_tools_only(manager, mock_arcade_client, make_tool): - """ - Test the internal _retrieve_tool_definitions method by specifying tools only. - """ - # Arrange - mock_tool = make_tool("GoogleSearch_Search") - mock_arcade_client.tools.get.return_value = mock_tool - - # Act - results = manager._retrieve_tool_definitions( - tools=["GoogleSearch_Search"], toolkits=None - ) - - # Assert - assert len(results) == 1 - assert results[0].fully_qualified_name == "GoogleSearch_Search" - mock_arcade_client.tools.get.assert_called_once_with(name="GoogleSearch_Search") - - -def test_retrieve_tool_definitions_toolkits_only( - manager, mock_arcade_client, make_tool -): - """ - Test the internal _retrieve_tool_definitions method by specifying toolkits. - """ - # Arrange - mock_tool = make_tool("Search_SearchBing") - mock_arcade_client.tools.list.return_value = SyncOffsetPage(items=[mock_tool]) - - # Act - results = manager._retrieve_tool_definitions(tools=None, toolkits=["Search"]) - - # Assert - assert len(results) == 1 - assert results[0].fully_qualified_name == "Search_SearchBing" - mock_arcade_client.tools.list.assert_called_once_with( - toolkit="Search", limit=NOT_GIVEN, offset=NOT_GIVEN - ) - - -def test_retrieve_tool_definitions_raise_on_empty(manager): - """ - Test that _retrieve_tool_definitions raises ValueError when no tools or toolkits - are provided and raise_on_empty is True. - """ - # Act & Assert - with pytest.raises(ValueError) as excinfo: - manager._retrieve_tool_definitions( - tools=None, toolkits=None, raise_on_empty=True - ) - - assert "No tools or toolkits provided" in str(excinfo.value) - - -def test_retrieve_tool_definitions_empty_no_raise(manager): - """ - Test that _retrieve_tool_definitions returns empty list when no tools or toolkits - are provided and raise_on_empty is False. - """ - # Act - results = manager._retrieve_tool_definitions( - tools=None, toolkits=None, raise_on_empty=False - ) - - # Assert - assert results == [] - - -@pytest.mark.asyncio -async def test_retrieve_tool_definitions_with_limit_offset_parameterized( - manager_fixture, mock_arcade_client, async_mock_arcade_client, make_tool -): - """ - Test that _retrieve_tool_definitions respects limit and offset parameters. - """ - # Arrange - manager, is_async = manager_fixture - client = async_mock_arcade_client if is_async else mock_arcade_client - - mock_tool = make_tool("Search_SearchGoogle") - page_cls = AsyncOffsetPage if is_async else SyncOffsetPage - client.tools.list.return_value = page_cls(items=[mock_tool]) - - # Act - if is_async: - results = await manager._retrieve_tool_definitions( - toolkits=["Search"], limit=10, offset=5 - ) - else: - results = manager._retrieve_tool_definitions( - toolkits=["Search"], limit=10, offset=5 - ) - - # Assert - assert len(results) > 0 - client.tools.list.assert_called_once_with(toolkit="Search", limit=10, offset=5) - - -def test_get_client_config_with_kwargs(): - """ - Test that _get_client_config prioritizes kwargs over environment variables. - """ - # Arrange - manager = ToolManager(client=MagicMock()) # Client won't be used here - - # Act - with patch.dict( - "os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"} - ): - result = manager._get_client_config(api_key="kwarg_key", base_url="kwarg_url") - - # Assert - assert result["api_key"] == "kwarg_key" - assert result["base_url"] == "kwarg_url" - - -def test_get_client_config_with_env_vars(): - """ - Test that _get_client_config falls back to environment variables when kwargs not provided. - """ - # Arrange - manager = ToolManager(client=MagicMock()) # Client won't be used here - - # Act - with patch.dict( - "os.environ", {"ARCADE_API_KEY": "env_key", "ARCADE_BASE_URL": "env_url"} - ): - result = manager._get_client_config() - - # Assert - assert result["api_key"] == "env_key" - assert result["base_url"] == "env_url" - - -def test_getitem_access(manager, make_tool): - """ - Test that __getitem__ allows dictionary-style access to tools. - """ - # Arrange - tool_name = "Search_SearchGoogle" - mock_tool_def = make_tool(tool_name) - manager._tools[tool_name] = mock_tool_def - - # Act - definition = manager[tool_name] - - # Assert - assert definition == mock_tool_def - - -def test_getitem_missing(manager): - """ - Test that __getitem__ raises ValueError for missing tools. - """ - # Act & Assert - with pytest.raises(ValueError) as excinfo: - _ = manager["Nonexistent.Tool"] - - assert "Tool 'Nonexistent.Tool' not found" in str(excinfo.value) - - -def test_create_tool_map_with_underscores(make_tool): - """ - Test the _create_tool_map function with use_underscores=True. - """ - # Arrange - from langchain_arcade.manager import _create_tool_map - - tool1 = make_tool("GoogleSearch.Search") - tool2 = make_tool("Gmail.SendEmail") - - # Act - result = _create_tool_map([tool1, tool2], use_underscores=True) - - # Assert - assert "GoogleSearch_Search" in result - assert "Gmail_SendEmail" in result - assert len(result) == 2 - - -def test_create_tool_map_with_dots(make_tool): - """ - Test the _create_tool_map function with use_underscores=False. - """ - # Arrange - from langchain_arcade.manager import _create_tool_map - - tool1 = make_tool("GoogleSearch.Search") - tool2 = make_tool("Gmail.SendEmail") - - # Act - result = _create_tool_map([tool1, tool2], use_underscores=False) - - # Assert - assert "GoogleSearch.Search" in result - assert "Gmail.SendEmail" in result - assert len(result) == 2 diff --git a/contrib/langchain/tox.ini b/contrib/langchain/tox.ini deleted file mode 100644 index dd7ffaaa..00000000 --- a/contrib/langchain/tox.ini +++ /dev/null @@ -1,16 +0,0 @@ -[tox] -skipsdist = true -envlist = py310, py311, py312 - -[gh-actions] -python = - 3.10: py310 - 3.11: py311 - 3.12: py312 - -[testenv] -passenv = PYTHON_VERSION -allowlist_externals = uv -commands = - uv sync --active --all-extras - uv pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml