From 44563fce5b893e767c6cb8bdaa518123ce2efa2a Mon Sep 17 00:00:00 2001 From: Mateo Torres Date: Mon, 2 Feb 2026 21:31:26 +0000 Subject: [PATCH] =?UTF-8?q?Revert=20"=F0=9F=AA=93=20langchain-arcade"=20(#?= =?UTF-8?q?760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts ArcadeAI/arcade-mcp#759 --- > [!NOTE] > **Medium Risk** > Adds a new LangChain/LangGraph integration layer that wraps and executes Arcade tools, including authorization and error/interrupt handling; bugs here could impact tool execution semantics for adopters. Changes are mostly additive and scoped to `contrib/langchain`. > > **Overview** > Re-introduces a standalone `contrib/langchain` Python package (`langchain-arcade`) to expose Arcade tools as LangChain `StructuredTool`s. > > Adds sync/async `ToolManager` implementations plus utilities to generate Pydantic arg schemas from `ToolDefinition`, optionally rewrite tool names (underscores vs dots), and handle authorization via LangGraph `NodeInterrupt` or structured error responses. > > Includes packaging/dev scaffolding (`pyproject.toml`, `tox.ini`, `Makefile`, `.gitignore`, `LICENSE`, `README`) and a comprehensive test suite covering manager behaviors and auth flows. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit abd23b6d954470cb1e7376158468c0e59cdc7d7a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- contrib/langchain/.gitignore | 175 ++++ contrib/langchain/LICENSE | 21 + contrib/langchain/Makefile | 47 + contrib/langchain/README.md | 175 ++++ .../langchain/langchain_arcade/__init__.py | 7 + .../langchain/langchain_arcade/_utilities.py | 313 +++++++ contrib/langchain/langchain_arcade/manager.py | 848 ++++++++++++++++++ contrib/langchain/langchain_arcade/py.typed | 0 contrib/langchain/pyproject.toml | 59 ++ contrib/langchain/tests/conftest.py | 33 + contrib/langchain/tests/test_manager.py | 738 +++++++++++++++ contrib/langchain/tox.ini | 16 + 12 files changed, 2432 insertions(+) create mode 100644 contrib/langchain/.gitignore create mode 100644 contrib/langchain/LICENSE create mode 100644 contrib/langchain/Makefile create mode 100644 contrib/langchain/README.md create mode 100644 contrib/langchain/langchain_arcade/__init__.py create mode 100644 contrib/langchain/langchain_arcade/_utilities.py create mode 100644 contrib/langchain/langchain_arcade/manager.py create mode 100644 contrib/langchain/langchain_arcade/py.typed create mode 100644 contrib/langchain/pyproject.toml create mode 100644 contrib/langchain/tests/conftest.py create mode 100644 contrib/langchain/tests/test_manager.py create mode 100644 contrib/langchain/tox.ini diff --git a/contrib/langchain/.gitignore b/contrib/langchain/.gitignore new file mode 100644 index 00000000..4865054a --- /dev/null +++ b/contrib/langchain/.gitignore @@ -0,0 +1,175 @@ +.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 new file mode 100644 index 00000000..dfbb8b76 --- /dev/null +++ b/contrib/langchain/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..761be0eb --- /dev/null +++ b/contrib/langchain/Makefile @@ -0,0 +1,47 @@ +.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 new file mode 100644 index 00000000..22dfcf85 --- /dev/null +++ b/contrib/langchain/README.md @@ -0,0 +1,175 @@ +

+ + +

+
+

Arcade Langchain Integration

+ + License + + + Downloads + + PyPI + + + +
+ +

+ 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 new file mode 100644 index 00000000..90af2fdf --- /dev/null +++ b/contrib/langchain/langchain_arcade/__init__.py @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000..1176cb3c --- /dev/null +++ b/contrib/langchain/langchain_arcade/_utilities.py @@ -0,0 +1,313 @@ +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 new file mode 100644 index 00000000..03a6997e --- /dev/null +++ b/contrib/langchain/langchain_arcade/manager.py @@ -0,0 +1,848 @@ +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 new file mode 100644 index 00000000..e69de29b diff --git a/contrib/langchain/pyproject.toml b/contrib/langchain/pyproject.toml new file mode 100644 index 00000000..89ab23d3 --- /dev/null +++ b/contrib/langchain/pyproject.toml @@ -0,0 +1,59 @@ +[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 new file mode 100644 index 00000000..43d0fdb6 --- /dev/null +++ b/contrib/langchain/tests/conftest.py @@ -0,0 +1,33 @@ +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 new file mode 100644 index 00000000..2aa19879 --- /dev/null +++ b/contrib/langchain/tests/test_manager.py @@ -0,0 +1,738 @@ +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 new file mode 100644 index 00000000..dd7ffaaa --- /dev/null +++ b/contrib/langchain/tox.ini @@ -0,0 +1,16 @@ +[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