Deprecate langchain-arcade (#761)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > This is mostly a packaging/documentation change, but it is a breaking change because the integration code and dependencies are removed, so any consumers importing `ToolManager`/`AsyncToolManager` will fail at runtime. > > **Overview** > Marks `langchain-arcade` as **deprecated/inactive** and strips it down to a stub package. > > The PR removes the LangChain/LangGraph integration implementation (`manager.py`, `_utilities.py`), tests, and local dev tooling (`Makefile`, `tox.ini`), and updates `README.md` to a deprecation notice pointing users to `docs.arcade.dev`. > > `pyproject.toml` is bumped to `2.0.0`, clears dependencies, and updates metadata/URLs; importing `langchain_arcade` now only emits a `DeprecationWarning` and exports nothing. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d3db1346f6399af90dcc516ffe3755bf26cb6f87. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
This commit is contained in:
parent
44563fce5b
commit
10fd728fcf
9 changed files with 57 additions and 2212 deletions
|
|
@ -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
|
||||
|
|
@ -1,175 +1,34 @@
|
|||
<h3 align="center">
|
||||
<a name="readme-top"></a>
|
||||
<img
|
||||
src="https://docs.arcade.dev/images/logo/arcade-logo.png"
|
||||
>
|
||||
</h3>
|
||||
<div align="center">
|
||||
<h3>Arcade Langchain Integration</h3>
|
||||
<a href="https://github.com/arcadeai/langchain-arcade/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://pepy.tech/project/langchain-arcade">
|
||||
<img src="https://static.pepy.tech/badge/langchain-arcade" alt="Downloads">
|
||||
<a href="https://pypi.org/project/langchain-arcade/">
|
||||
<img src="https://img.shields.io/pypi/v/langchain-arcade.svg" alt="PyPI">
|
||||
</a>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
<h1>⚠️ DEPRECATED ⚠️</h1>
|
||||
<h3>langchain-arcade is no longer maintained</h3>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Important Notice
|
||||
|
||||
**This package has been deprecated and is no longer maintained.**
|
||||
|
||||
The `langchain-arcade` package is no longer needed. Arcade now provides better ways to integrate with your AI applications.
|
||||
|
||||
## What Should I Use Instead?
|
||||
|
||||
Please visit **[docs.arcade.dev](https://docs.arcade.dev)** for the latest documentation on how to integrate Arcade tools into your applications.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you were previously using `langchain-arcade`, we recommend:
|
||||
|
||||
1. Visit [docs.arcade.dev](https://docs.arcade.dev) to learn about the new integration options
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.arcade.dev" target="_blank">Arcade Documentation</a> •
|
||||
<a href="https://docs.arcade.dev/en/resources/integrations" target="_blank">Servers</a> •
|
||||
<a href="https://github.com/ArcadeAI/arcade-py" target="_blank">Python Client</a> •
|
||||
<a href="https://github.com/ArcadeAI/arcade-js" target="_blank">JavaScript Client</a>
|
||||
Thank you for using langchain-arcade. We hope to see you using Arcade's new integrations!
|
||||
</p>
|
||||
|
||||
## 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).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
from .manager import ArcadeToolManager, AsyncToolManager, ToolManager
|
||||
import warnings
|
||||
|
||||
__all__ = [
|
||||
"ArcadeToolManager", # Deprecated
|
||||
"AsyncToolManager",
|
||||
"ToolManager",
|
||||
]
|
||||
warnings.warn(
|
||||
"\n" + "=" * 70 + "\n"
|
||||
"DEPRECATION NOTICE: langchain-arcade is no longer maintained.\n"
|
||||
"\n"
|
||||
"This package has been deprecated. Please visit https://docs.arcade.dev\n"
|
||||
"for the latest documentation on integrating Arcade tools into your\n"
|
||||
"applications.\n"
|
||||
"\n"
|
||||
"Arcade now supports MCP (Model Context Protocol) and direct API\n"
|
||||
"integration via the Arcade Python SDK.\n" + "=" * 70,
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
__all__: list[str] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -4,56 +4,27 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "langchain-arcade"
|
||||
version = "1.4.5"
|
||||
description = "An integration package connecting Arcade and Langchain/LangGraph"
|
||||
version = "2.0.0"
|
||||
description = "This package is no longer maintained. Please visit https://docs.arcade.dev for the latest Arcade integrations."
|
||||
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",
|
||||
keywords = ["deprecated", "arcade", "langchain"]
|
||||
classifiers = [
|
||||
"Development Status :: 7 - Inactive",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
|
||||
[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"]
|
||||
[project.urls]
|
||||
Homepage = "https://docs.arcade.dev"
|
||||
Documentation = "https://docs.arcade.dev"
|
||||
Repository = "https://github.com/arcadeai/arcade-mcp"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = [ "langchain_arcade",]
|
||||
packages = ["langchain_arcade"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue