Pass parsed inputs through to tool _run (#4309)

parallel_dir_loader
Zander Chase 1 year ago committed by GitHub
parent 35c9e6ab40
commit 8b284f9ad0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,232 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Gmail Toolkit\n",
"\n",
"This notebook walks through connecting a LangChain email to the Gmail API.\n",
"\n",
"To use this toolkit, you will need to set up your credentials explained in the [Gmail API docs](https://developers.google.com/gmail/api/quickstart/python#authorize_credentials_for_a_desktop_application). Once you've downloaded the `credentials.json` file, you can start using the Gmail API. Once this is done, we'll install the required libraries."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"!pip install --upgrade google-api-python-client > /dev/null\n",
"!pip install --upgrade google-auth-oauthlib > /dev/null\n",
"!pip install --upgrade google-auth-httplib2 > /dev/null\n",
"!pip install beautifulsoup4 > /dev/null # This is optional but is useful for parsing HTML messages"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Create the Toolkit\n",
"\n",
"By default the toolkit reads the local `credentials.json` file. You can also manually provide a `Credentials` object."
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {
"tags": []
},
"outputs": [],
"source": [
"from langchain.agents.agent_toolkits import GmailToolkit\n",
"\n",
"toolkit = GmailToolkit() "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Customizing Authentication\n",
"\n",
"Behind the scenes, a `googleapi` resource is created using the following methods. \n",
"you can manually build a `googleapi` resource for more auth control. "
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {
"tags": []
},
"outputs": [],
"source": [
"from langchain.tools.gmail.utils import build_resource_service, get_gmail_credentials\n",
"\n",
"# Can review scopes here https://developers.google.com/gmail/api/auth/scopes\n",
"# For instance, readonly scope is 'https://www.googleapis.com/auth/gmail.readonly'\n",
"credentials = get_gmail_credentials(\n",
" token_file='token.json',\n",
" scopes=[\"https://mail.google.com/\"],\n",
" client_secrets_file=\"credentials.json\",\n",
")\n",
"api_resource = build_resource_service(credentials=credentials)\n",
"toolkit = GmailToolkit(api_resource=api_resource)"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {
"tags": []
},
"outputs": [
{
"data": {
"text/plain": [
"[GmailCreateDraft(name='create_gmail_draft', description='Use this tool to create a draft email with the provided message fields.', args_schema=<class 'langchain.tools.gmail.create_draft.CreateDraftSchema'>, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=<googleapiclient.discovery.Resource object at 0x10e5c6d10>),\n",
" GmailSendMessage(name='send_gmail_message', description='Use this tool to send email messages. The input is the message, recipents', args_schema=None, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=<googleapiclient.discovery.Resource object at 0x10e5c6d10>),\n",
" GmailSearch(name='search_gmail', description=('Use this tool to search for email messages or threads. The input must be a valid Gmail query. The output is a JSON list of the requested resource.',), args_schema=<class 'langchain.tools.gmail.search.SearchArgsSchema'>, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=<googleapiclient.discovery.Resource object at 0x10e5c6d10>),\n",
" GmailGetMessage(name='get_gmail_message', description='Use this tool to fetch an email by message ID. Returns the thread ID, snipet, body, subject, and sender.', args_schema=<class 'langchain.tools.gmail.get_message.SearchArgsSchema'>, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=<googleapiclient.discovery.Resource object at 0x10e5c6d10>),\n",
" GmailGetThread(name='get_gmail_thread', description=('Use this tool to search for email messages. The input must be a valid Gmail query. The output is a JSON list of messages.',), args_schema=<class 'langchain.tools.gmail.get_thread.GetThreadSchema'>, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=<googleapiclient.discovery.Resource object at 0x10e5c6d10>)]"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"tools = toolkit.get_tools()\n",
"tools"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Use within an Agent"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {
"tags": []
},
"outputs": [],
"source": [
"from langchain import OpenAI\n",
"from langchain.agents import initialize_agent, AgentType"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {
"tags": []
},
"outputs": [],
"source": [
"llm = OpenAI(temperature=0)\n",
"agent = initialize_agent(\n",
" tools=toolkit.get_tools(),\n",
" llm=llm,\n",
" agent=AgentType.STRUCTURED_CHAT_ZERO_SHOT_REACT_DESCRIPTION,\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {
"tags": []
},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"WARNING:root:Failed to load default session, using empty session: 0\n",
"WARNING:root:Failed to persist run: {\"detail\":\"Not Found\"}\n"
]
},
{
"data": {
"text/plain": [
"'I have created a draft email for you to edit. The draft Id is r5681294731961864018.'"
]
},
"execution_count": 19,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"agent.run(\"Create a gmail draft for me to edit of a letter from the perspective of a sentient parrot\"\n",
" \" who is looking to collaborate on some research with her\"\n",
" \" estranged friend, a cat. Under no circumstances may you send the message, however.\")"
]
},
{
"cell_type": "code",
"execution_count": 24,
"metadata": {
"tags": []
},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"WARNING:root:Failed to load default session, using empty session: 0\n",
"WARNING:root:Failed to persist run: {\"detail\":\"Not Found\"}\n"
]
},
{
"data": {
"text/plain": [
"\"The latest email in your drafts is from hopefulparrot@gmail.com with the subject 'Collaboration Opportunity'. The body of the email reads: 'Dear [Friend], I hope this letter finds you well. I am writing to you in the hopes of rekindling our friendship and to discuss the possibility of collaborating on some research together. I know that we have had our differences in the past, but I believe that we can put them aside and work together for the greater good. I look forward to hearing from you. Sincerely, [Parrot]'\""
]
},
"execution_count": 24,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"agent.run(\"Could you search in my drafts for the latest email?\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.2"
}
},
"nbformat": 4,
"nbformat_minor": 4
}

@ -0,0 +1,90 @@
{
"cells": [
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
"## Gmail Toolkit\n",
"\n",
"**The Gmail Toolkit** allows you to create drafts, send email, and search for messages and threads using natural language.\n",
"\n",
"As a prerequisite, you will need to register with Google and generate a `credentials.json` file in the directory where you run this loader. See [here](https://developers.google.com/workspace/guides/create-credentials) for instructions.\n",
"\n",
"This example goes over how to use the Gmail Toolkit:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from langchain.llms import OpenAI\n",
"from langchain.agents.agent_toolkits.gmail.base import create_gmail_agent\n",
"import json\n",
"\n",
"llm = OpenAI(verbose=True)\n",
"gmail_agent = create_gmail_agent(llm=llm, sender_name=\"Alice\", verbose=True)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"command = \"search for all messages during november 2022\"\n",
"output = gmail_agent.run(command)\n",
"\n",
"messages = json.loads(output)\n",
"\n",
"print(\"Messages:\")\n",
"for message in messages:\n",
" print(f\"{message['id']}: {message['snippet']}\")\n",
"\n",
"id = messages[0][\"id\"]\n",
"\n",
"command = f\"get the body for message id {id}\"\n",
"\n",
"output = gmail_agent.run(command)\n",
"\n",
"print(f\"Message body: {output}\")\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"command = \"create a draft email to bob@example.com explaining why I can't make the meeting next week.\"\n",
"output = gmail_agent.run(command)\n",
"\n",
"print(output)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "agent-ui",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.8"
},
"orig_nbformat": 4
},
"nbformat": 4,
"nbformat_minor": 2
}

@ -4,6 +4,7 @@ from langchain.agents.agent_toolkits.csv.base import create_csv_agent
from langchain.agents.agent_toolkits.file_management.toolkit import ( from langchain.agents.agent_toolkits.file_management.toolkit import (
FileManagementToolkit, FileManagementToolkit,
) )
from langchain.agents.agent_toolkits.gmail.toolkit import GmailToolkit
from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit from langchain.agents.agent_toolkits.jira.toolkit import JiraToolkit
from langchain.agents.agent_toolkits.json.base import create_json_agent from langchain.agents.agent_toolkits.json.base import create_json_agent
from langchain.agents.agent_toolkits.json.toolkit import JsonToolkit from langchain.agents.agent_toolkits.json.toolkit import JsonToolkit
@ -51,6 +52,7 @@ __all__ = [
"create_spark_dataframe_agent", "create_spark_dataframe_agent",
"create_csv_agent", "create_csv_agent",
"ZapierToolkit", "ZapierToolkit",
"GmailToolkit",
"JiraToolkit", "JiraToolkit",
"FileManagementToolkit", "FileManagementToolkit",
"PlayWrightBrowserToolkit", "PlayWrightBrowserToolkit",

@ -0,0 +1,48 @@
from __future__ import annotations
from typing import TYPE_CHECKING, List
from pydantic import Field
from langchain.agents.agent_toolkits.base import BaseToolkit
from langchain.tools import BaseTool
from langchain.tools.gmail.create_draft import GmailCreateDraft
from langchain.tools.gmail.get_message import GmailGetMessage
from langchain.tools.gmail.get_thread import GmailGetThread
from langchain.tools.gmail.search import GmailSearch
from langchain.tools.gmail.send_message import GmailSendMessage
from langchain.tools.gmail.utils import build_resource_service
if TYPE_CHECKING:
# This is for linting and IDE typehints
from googleapiclient.discovery import Resource
else:
try:
# We do this so pydantic can resolve the types when instantiating
from googleapiclient.discovery import Resource
except ImportError:
pass
SCOPES = ["https://mail.google.com/"]
class GmailToolkit(BaseToolkit):
"""Toolkit for interacting with Gmail."""
api_resource: Resource = Field(default_factory=build_resource_service)
class Config:
"""Pydantic config."""
arbitrary_types_allowed = True
def get_tools(self) -> List[BaseTool]:
"""Get the tools in the toolkit."""
return [
GmailCreateDraft(api_resource=self.api_resource),
GmailSendMessage(api_resource=self.api_resource),
GmailSearch(api_resource=self.api_resource),
GmailGetMessage(api_resource=self.api_resource),
GmailGetThread(api_resource=self.api_resource),
]

@ -10,6 +10,13 @@ from langchain.tools.file_management.list_dir import ListDirectoryTool
from langchain.tools.file_management.move import MoveFileTool from langchain.tools.file_management.move import MoveFileTool
from langchain.tools.file_management.read import ReadFileTool from langchain.tools.file_management.read import ReadFileTool
from langchain.tools.file_management.write import WriteFileTool from langchain.tools.file_management.write import WriteFileTool
from langchain.tools.gmail import (
GmailCreateDraft,
GmailGetMessage,
GmailGetThread,
GmailSearch,
GmailSendMessage,
)
from langchain.tools.google_places.tool import GooglePlacesTool from langchain.tools.google_places.tool import GooglePlacesTool
from langchain.tools.google_search.tool import GoogleSearchResults, GoogleSearchRun from langchain.tools.google_search.tool import GoogleSearchResults, GoogleSearchRun
from langchain.tools.google_serper.tool import GoogleSerperResults, GoogleSerperRun from langchain.tools.google_serper.tool import GoogleSerperResults, GoogleSerperRun
@ -56,6 +63,11 @@ __all__ = [
"ExtractTextTool", "ExtractTextTool",
"FileSearchTool", "FileSearchTool",
"GetElementsTool", "GetElementsTool",
"GmailCreateDraft",
"GmailGetMessage",
"GmailGetThread",
"GmailSearch",
"GmailSendMessage",
"GooglePlacesTool", "GooglePlacesTool",
"GoogleSearchResults", "GoogleSearchResults",
"GoogleSearchRun", "GoogleSearchRun",

@ -160,16 +160,19 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass):
def _parse_input( def _parse_input(
self, self,
tool_input: Union[str, Dict], tool_input: Union[str, Dict],
) -> None: ) -> Union[str, Dict[str, Any]]:
"""Convert tool input to pydantic model.""" """Convert tool input to pydantic model."""
input_args = self.args_schema input_args = self.args_schema
if isinstance(tool_input, str): if isinstance(tool_input, str):
if input_args is not None: if input_args is not None:
key_ = next(iter(input_args.__fields__.keys())) key_ = next(iter(input_args.__fields__.keys()))
input_args.validate({key_: tool_input}) input_args.validate({key_: tool_input})
return tool_input
else: else:
if input_args is not None: if input_args is not None:
input_args.validate(tool_input) result = input_args.parse_obj(tool_input)
return {k: v for k, v in result.dict().items() if k in tool_input}
return tool_input
@root_validator() @root_validator()
def raise_deprecation(cls, values: Dict) -> Dict: def raise_deprecation(cls, values: Dict) -> Dict:
@ -224,7 +227,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass):
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Run the tool.""" """Run the tool."""
self._parse_input(tool_input) parsed_input = self._parse_input(tool_input)
if not self.verbose and verbose is not None: if not self.verbose and verbose is not None:
verbose_ = verbose verbose_ = verbose
else: else:
@ -241,7 +244,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass):
**kwargs, **kwargs,
) )
try: try:
tool_args, tool_kwargs = self._to_args_and_kwargs(tool_input) tool_args, tool_kwargs = self._to_args_and_kwargs(parsed_input)
observation = ( observation = (
self._run(*tool_args, run_manager=run_manager, **tool_kwargs) self._run(*tool_args, run_manager=run_manager, **tool_kwargs)
if new_arg_supported if new_arg_supported
@ -263,7 +266,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass):
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Run the tool asynchronously.""" """Run the tool asynchronously."""
self._parse_input(tool_input) parsed_input = self._parse_input(tool_input)
if not self.verbose and verbose is not None: if not self.verbose and verbose is not None:
verbose_ = verbose verbose_ = verbose
else: else:
@ -280,7 +283,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass):
) )
try: try:
# We then call the tool on the tool input to get an observation # We then call the tool on the tool input to get an observation
tool_args, tool_kwargs = self._to_args_and_kwargs(tool_input) tool_args, tool_kwargs = self._to_args_and_kwargs(parsed_input)
observation = ( observation = (
await self._arun(*tool_args, run_manager=run_manager, **tool_kwargs) await self._arun(*tool_args, run_manager=run_manager, **tool_kwargs)
if new_arg_supported if new_arg_supported

@ -0,0 +1,17 @@
"""Gmail tools."""
from langchain.tools.gmail.create_draft import GmailCreateDraft
from langchain.tools.gmail.get_message import GmailGetMessage
from langchain.tools.gmail.get_thread import GmailGetThread
from langchain.tools.gmail.search import GmailSearch
from langchain.tools.gmail.send_message import GmailSendMessage
from langchain.tools.gmail.utils import get_gmail_credentials
__all__ = [
"GmailCreateDraft",
"GmailSendMessage",
"GmailSearch",
"GmailGetMessage",
"GmailGetThread",
"get_gmail_credentials",
]

@ -0,0 +1,27 @@
"""Base class for Gmail tools."""
from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import Field
from langchain.tools.base import BaseTool
from langchain.tools.gmail.utils import build_resource_service
if TYPE_CHECKING:
# This is for linting and IDE typehints
from googleapiclient.discovery import Resource
else:
try:
# We do this so pydantic can resolve the types when instantiating
from googleapiclient.discovery import Resource
except ImportError:
pass
class GmailBaseTool(BaseTool):
api_resource: Resource = Field(default_factory=build_resource_service)
@classmethod
def from_api_resource(cls, api_resource: Resource) -> "GmailBaseTool":
return cls(service=api_resource)

@ -0,0 +1,97 @@
import base64
from email.message import EmailMessage
from typing import List, Optional, Type
from pydantic import BaseModel, Field
from langchain.callbacks.manager import (
AsyncCallbackManagerForToolRun,
CallbackManagerForToolRun,
)
from langchain.tools.gmail.base import GmailBaseTool
class CreateDraftSchema(BaseModel):
message: str = Field(
...,
description="The message to include in the draft.",
)
to: List[str] = Field(
...,
description="The list of recipients.",
)
subject: str = Field(
...,
description="The subject of the message.",
)
cc: Optional[List[str]] = Field(
None,
description="The list of CC recipients.",
)
bcc: Optional[List[str]] = Field(
None,
description="The list of BCC recipients.",
)
class GmailCreateDraft(GmailBaseTool):
name: str = "create_gmail_draft"
description: str = (
"Use this tool to create a draft email with the provided message fields."
)
args_schema: Type[CreateDraftSchema] = CreateDraftSchema
def _prepare_draft_message(
self,
message: str,
to: List[str],
subject: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
) -> dict:
draft_message = EmailMessage()
draft_message.set_content(message)
draft_message["To"] = ", ".join(to)
draft_message["Subject"] = subject
if cc is not None:
draft_message["Cc"] = ", ".join(cc)
if bcc is not None:
draft_message["Bcc"] = ", ".join(bcc)
encoded_message = base64.urlsafe_b64encode(draft_message.as_bytes()).decode()
return {"message": {"raw": encoded_message}}
def _run(
self,
message: str,
to: List[str],
subject: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> str:
try:
create_message = self._prepare_draft_message(message, to, subject, cc, bcc)
draft = (
self.api_resource.users()
.drafts()
.create(userId="me", body=create_message)
.execute()
)
output = f'Draft created. Draft Id: {draft["id"]}'
return output
except Exception as e:
raise Exception(f"An error occurred: {e}")
async def _arun(
self,
message: str,
to: List[str],
subject: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
) -> str:
raise NotImplementedError(f"The tool {self.name} does not support async yet.")

@ -0,0 +1,68 @@
import base64
import email
from typing import Dict, Optional, Type
from pydantic import BaseModel, Field
from langchain.callbacks.manager import (
AsyncCallbackManagerForToolRun,
CallbackManagerForToolRun,
)
from langchain.tools.gmail.base import GmailBaseTool
from langchain.tools.gmail.utils import clean_email_body
class SearchArgsSchema(BaseModel):
message_id: str = Field(
...,
description="The unique ID of the email message, retrieved from a search.",
)
class GmailGetMessage(GmailBaseTool):
name: str = "get_gmail_message"
description: str = (
"Use this tool to fetch an email by message ID."
" Returns the thread ID, snipet, body, subject, and sender."
)
args_schema: Type[SearchArgsSchema] = SearchArgsSchema
def _run(
self,
message_id: str,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> Dict:
"""Run the tool."""
query = (
self.api_resource.users()
.messages()
.get(userId="me", format="raw", id=message_id)
)
message_data = query.execute()
raw_message = base64.urlsafe_b64decode(message_data["raw"])
email_msg = email.message_from_bytes(raw_message)
subject = email_msg["Subject"]
sender = email_msg["From"]
message_body = email_msg.get_payload()
body = clean_email_body(message_body)
return {
"id": message_id,
"threadId": message_data["threadId"],
"snippet": message_data["snippet"],
"body": body,
"subject": subject,
"sender": sender,
}
async def _arun(
self,
message_id: str,
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
) -> Dict:
"""Run the tool."""
raise NotImplementedError

@ -0,0 +1,55 @@
from typing import Dict, Optional, Type
from pydantic import BaseModel, Field
from langchain.callbacks.manager import (
AsyncCallbackManagerForToolRun,
CallbackManagerForToolRun,
)
from langchain.tools.gmail.base import GmailBaseTool
class GetThreadSchema(BaseModel):
# From https://support.google.com/mail/answer/7190?hl=en
thread_id: str = Field(
...,
description="The thread ID.",
)
class GmailGetThread(GmailBaseTool):
name: str = "get_gmail_thread"
description: str = (
"Use this tool to search for email messages."
" The input must be a valid Gmail query."
" The output is a JSON list of messages."
)
args_schema: Type[GetThreadSchema] = GetThreadSchema
def _run(
self,
thread_id: str,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> Dict:
"""Run the tool."""
query = self.api_resource.users().threads().get(userId="me", id=thread_id)
thread_data = query.execute()
if not isinstance(thread_data, dict):
raise ValueError("The output of the query must be a list.")
messages = thread_data["messages"]
thread_data["messages"] = []
keys_to_keep = ["id", "snippet", "snippet"]
# TODO: Parse body.
for message in messages:
thread_data["messages"].append(
{k: message[k] for k in keys_to_keep if k in message}
)
return thread_data
async def _arun(
self,
thread_id: str,
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
) -> Dict:
"""Run the tool."""
raise NotImplementedError

@ -0,0 +1,138 @@
import base64
import email
from enum import Enum
from typing import Any, Dict, List, Optional, Type
from pydantic import BaseModel, Field
from langchain.callbacks.manager import (
AsyncCallbackManagerForToolRun,
CallbackManagerForToolRun,
)
from langchain.tools.gmail.base import GmailBaseTool
from langchain.tools.gmail.utils import clean_email_body
class Resource(str, Enum):
THREADS = "threads"
MESSAGES = "messages"
class SearchArgsSchema(BaseModel):
# From https://support.google.com/mail/answer/7190?hl=en
query: str = Field(
...,
description="The Gmail query. Example filters include from:sender,"
" to:recipient, subject:subject, -filtered_term,"
" in:folder, is:important|read|starred, after:year/mo/date, "
"before:year/mo/date, label:label_name"
' "exact phrase".'
" Search newer/older than using d (day), m (month), and y (year): "
"newer_than:2d, older_than:1y."
" Attachments with extension example: filename:pdf. Multiple term"
" matching example: from:amy OR from:david.",
)
resource: Resource = Field(
default=Resource.MESSAGES,
description="Whether to search for threads or messages.",
)
max_results: int = Field(
default=10,
description="The maximum number of results to return.",
)
class GmailSearch(GmailBaseTool):
name: str = "search_gmail"
description: str = (
"Use this tool to search for email messages or threads."
" The input must be a valid Gmail query."
" The output is a JSON list of the requested resource."
)
args_schema: Type[SearchArgsSchema] = SearchArgsSchema
def _parse_threads(self, threads: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Add the thread message snippets to the thread results
results = []
for thread in threads:
thread_id = thread["id"]
thread_data = (
self.api_resource.users()
.threads()
.get(userId="me", id=thread_id)
.execute()
)
messages = thread_data["messages"]
thread["messages"] = []
for message in messages:
snippet = message["snippet"]
thread["messages"].append({"snippet": snippet, "id": message["id"]})
results.append(thread)
return results
def _parse_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
results = []
for message in messages:
message_id = message["id"]
message_data = (
self.api_resource.users()
.messages()
.get(userId="me", format="raw", id=message_id)
.execute()
)
raw_message = base64.urlsafe_b64decode(message_data["raw"])
email_msg = email.message_from_bytes(raw_message)
subject = email_msg["Subject"]
sender = email_msg["From"]
message_body = email_msg.get_payload()
body = clean_email_body(message_body)
results.append(
{
"id": message["id"],
"threadId": message_data["threadId"],
"snippet": message_data["snippet"],
"body": body,
"subject": subject,
"sender": sender,
}
)
return results
def _run(
self,
query: str,
resource: Resource = Resource.MESSAGES,
max_results: int = 10,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> List[Dict[str, Any]]:
"""Run the tool."""
results = (
self.api_resource.users()
.messages()
.list(userId="me", q=query, maxResults=max_results)
.execute()
.get(resource.value, [])
)
if resource == Resource.THREADS:
return self._parse_threads(results)
elif resource == Resource.MESSAGES:
return self._parse_messages(results)
else:
raise NotImplementedError(f"Resource of type {resource} not implemented.")
async def _arun(
self,
query: str,
resource: Resource = Resource.MESSAGES,
max_results: int = 10,
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
) -> List[Dict[str, Any]]:
"""Run the tool."""
raise NotImplementedError

@ -0,0 +1,100 @@
"""Send Gmail messages."""
import base64
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from langchain.callbacks.manager import (
AsyncCallbackManagerForToolRun,
CallbackManagerForToolRun,
)
from langchain.tools.gmail.base import GmailBaseTool
class SendMessageSchema(BaseModel):
message: str = Field(
...,
description="The message to send.",
)
to: List[str] = Field(
...,
description="The list of recipients.",
)
subject: str = Field(
...,
description="The subject of the message.",
)
cc: Optional[List[str]] = Field(
None,
description="The list of CC recipients.",
)
bcc: Optional[List[str]] = Field(
None,
description="The list of BCC recipients.",
)
class GmailSendMessage(GmailBaseTool):
name: str = "send_gmail_message"
description: str = (
"Use this tool to send email messages." " The input is the message, recipents"
)
def _prepare_message(
self,
message: str,
to: List[str],
subject: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Create a message for an email."""
mime_message = MIMEMultipart()
mime_message.attach(MIMEText(message, "html"))
mime_message["To"] = ", ".join(to)
mime_message["Subject"] = subject
if cc is not None:
mime_message["Cc"] = ", ".join(cc)
if bcc is not None:
mime_message["Bcc"] = ", ".join(bcc)
encoded_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode()
return {"raw": encoded_message}
def _run(
self,
message: str,
to: List[str],
subject: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForToolRun] = None,
) -> str:
"""Run the tool."""
try:
create_message = self._prepare_message(message, to, subject, cc=cc, bcc=bcc)
send_message = (
self.api_resource.users()
.messages()
.send(userId="me", body=create_message)
)
sent_message = send_message.execute()
return f'Message sent. Message Id: {sent_message["id"]}'
except Exception as error:
raise Exception(f"An error occurred: {error}")
async def _arun(
self,
message: str,
to: List[str],
subject: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
) -> str:
"""Run the tool asynchronously."""
raise NotImplementedError(f"The tool {self.name} does not support async yet.")

@ -0,0 +1,117 @@
"""Gmail tool utils."""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING, List, Optional, Tuple
if TYPE_CHECKING:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import Resource
from googleapiclient.discovery import build as build_resource
logger = logging.getLogger(__name__)
def import_google() -> Tuple[Request, Credentials]:
# google-auth-httplib2
try:
from google.auth.transport.requests import Request # noqa: F401
from google.oauth2.credentials import Credentials # noqa: F401
except ImportError:
raise ValueError(
"You need to install google-auth-httplib2 to use this toolkit. "
"Try running pip install --upgrade google-auth-httplib2"
)
return Request, Credentials
def import_installed_app_flow() -> InstalledAppFlow:
try:
from google_auth_oauthlib.flow import InstalledAppFlow
except ImportError:
raise ValueError(
"You need to install google-auth-oauthlib to use this toolkit. "
"Try running pip install --upgrade google-auth-oauthlib"
)
return InstalledAppFlow
def import_googleapiclient_resource_builder() -> build_resource:
try:
from googleapiclient.discovery import build
except ImportError:
raise ValueError(
"You need to install googleapiclient to use this toolkit. "
"Try running pip install --upgrade google-api-python-client"
)
return build
DEFAULT_SCOPES = ["https://mail.google.com/"]
DEFAULT_CREDS_TOKEN_FILE = "token.json"
DEFAULT_CLIENT_SECRETS_FILE = "credentials.json"
def get_gmail_credentials(
token_file: Optional[str] = None,
client_secrets_file: Optional[str] = None,
scopes: Optional[List[str]] = None,
) -> Credentials:
"""Get credentials."""
# From https://developers.google.com/gmail/api/quickstart/python
Request, Credentials = import_google()
InstalledAppFlow = import_installed_app_flow()
creds = None
scopes = scopes or DEFAULT_SCOPES
token_file = token_file or DEFAULT_CREDS_TOKEN_FILE
client_secrets_file = client_secrets_file or DEFAULT_CLIENT_SECRETS_FILE
# The file token.json stores the user's access and refresh tokens, and is
# created automatically when the authorization flow completes for the first
# time.
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, scopes)
# If there are no (valid) credentials available, let the user log in.
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
# https://developers.google.com/gmail/api/quickstart/python#authorize_credentials_for_a_desktop_application # noqa
flow = InstalledAppFlow.from_client_secrets_file(
client_secrets_file, scopes
)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open(token_file, "w") as token:
token.write(creds.to_json())
return creds
def build_resource_service(
credentials: Optional[Credentials] = None,
service_name: str = "gmail",
service_version: str = "v1",
) -> Resource:
"""Build a Gmail service."""
credentials = credentials or get_gmail_credentials()
builder = import_googleapiclient_resource_builder()
return builder(service_name, service_version, credentials=credentials)
def clean_email_body(body: str) -> str:
"""Clean email body."""
try:
from bs4 import BeautifulSoup
try:
soup = BeautifulSoup(str(body), "html.parser")
body = soup.get_text()
return str(body)
except Exception as e:
logger.error(e)
return str(body)
except ImportError:
logger.warning("BeautifulSoup not installed. Skipping cleaning.")
return str(body)

@ -1,5 +1,7 @@
"""Test the base tool implementation.""" """Test the base tool implementation."""
import json
from datetime import datetime from datetime import datetime
from enum import Enum
from functools import partial from functools import partial
from typing import Any, Optional, Type, Union from typing import Any, Optional, Type, Union
@ -224,9 +226,8 @@ def test_structured_args_decorator_no_infer_schema() -> None:
assert isinstance(structured_tool_input, BaseTool) assert isinstance(structured_tool_input, BaseTool)
assert structured_tool_input.name == "structured_tool_input" assert structured_tool_input.name == "structured_tool_input"
args = {"arg1": 1, "arg2": 0.001, "opt_arg": {"foo": "bar"}} args = {"arg1": 1, "arg2": 0.001, "opt_arg": {"foo": "bar"}}
expected_result = "1, 0.001, {'foo': 'bar'}"
with pytest.raises(ValueError): with pytest.raises(ValueError):
assert structured_tool_input.run(args) == expected_result assert structured_tool_input.run(args)
def test_structured_single_str_decorator_no_infer_schema() -> None: def test_structured_single_str_decorator_no_infer_schema() -> None:
@ -235,6 +236,7 @@ def test_structured_single_str_decorator_no_infer_schema() -> None:
@tool(infer_schema=False) @tool(infer_schema=False)
def unstructured_tool_input(tool_input: str) -> str: def unstructured_tool_input(tool_input: str) -> str:
"""Return the arguments directly.""" """Return the arguments directly."""
assert isinstance(tool_input, str)
return f"{tool_input}" return f"{tool_input}"
assert isinstance(unstructured_tool_input, BaseTool) assert isinstance(unstructured_tool_input, BaseTool)
@ -242,6 +244,40 @@ def test_structured_single_str_decorator_no_infer_schema() -> None:
assert unstructured_tool_input.run("foo") == "foo" assert unstructured_tool_input.run("foo") == "foo"
def test_structured_tool_types_parsed() -> None:
"""Test the non-primitive types are correctly passed to structured tools."""
class SomeEnum(Enum):
A = "a"
B = "b"
class SomeBaseModel(BaseModel):
foo: str
@tool
def structured_tool(
some_enum: SomeEnum,
some_base_model: SomeBaseModel,
) -> dict:
"""Return the arguments directly."""
return {
"some_enum": some_enum,
"some_base_model": some_base_model,
}
assert isinstance(structured_tool, StructuredTool)
args = {
"some_enum": SomeEnum.A.value,
"some_base_model": SomeBaseModel(foo="bar").dict(),
}
result = structured_tool.run(json.loads(json.dumps(args)))
expected = {
"some_enum": SomeEnum.A,
"some_base_model": SomeBaseModel(foo="bar"),
}
assert result == expected
def test_base_tool_inheritance_base_schema() -> None: def test_base_tool_inheritance_base_schema() -> None:
"""Test schema is correctly inferred when inheriting from BaseTool.""" """Test schema is correctly inferred when inheriting from BaseTool."""
@ -293,6 +329,8 @@ def test_tool_partial_function_args_schema() -> None:
"""Test args schema inference when the tool argument is a partial function.""" """Test args schema inference when the tool argument is a partial function."""
def func(tool_input: str, other_arg: str) -> str: def func(tool_input: str, other_arg: str) -> str:
assert isinstance(tool_input, str)
assert isinstance(other_arg, str)
return tool_input + other_arg return tool_input + other_arg
tool = Tool( tool = Tool(
@ -323,24 +361,27 @@ def test_named_tool_decorator() -> None:
@tool("search") @tool("search")
def search_api(query: str) -> str: def search_api(query: str) -> str:
"""Search the API for the query.""" """Search the API for the query."""
return "API result" assert isinstance(query, str)
return f"API result - {query}"
assert isinstance(search_api, BaseTool) assert isinstance(search_api, BaseTool)
assert search_api.name == "search" assert search_api.name == "search"
assert not search_api.return_direct assert not search_api.return_direct
assert search_api.run({"query": "foo"}) == "API result - foo"
def test_named_tool_decorator_return_direct() -> None: def test_named_tool_decorator_return_direct() -> None:
"""Test functionality when arguments and return direct are provided as input.""" """Test functionality when arguments and return direct are provided as input."""
@tool("search", return_direct=True) @tool("search", return_direct=True)
def search_api(query: str) -> str: def search_api(query: str, *args: Any) -> str:
"""Search the API for the query.""" """Search the API for the query."""
return "API result" return "API result"
assert isinstance(search_api, BaseTool) assert isinstance(search_api, BaseTool)
assert search_api.name == "search" assert search_api.name == "search"
assert search_api.return_direct assert search_api.return_direct
assert search_api.run({"query": "foo"}) == "API result"
def test_unnamed_tool_decorator_return_direct() -> None: def test_unnamed_tool_decorator_return_direct() -> None:
@ -349,11 +390,13 @@ def test_unnamed_tool_decorator_return_direct() -> None:
@tool(return_direct=True) @tool(return_direct=True)
def search_api(query: str) -> str: def search_api(query: str) -> str:
"""Search the API for the query.""" """Search the API for the query."""
assert isinstance(query, str)
return "API result" return "API result"
assert isinstance(search_api, BaseTool) assert isinstance(search_api, BaseTool)
assert search_api.name == "search_api" assert search_api.name == "search_api"
assert search_api.return_direct assert search_api.return_direct
assert search_api.run({"query": "foo"}) == "API result"
def test_tool_with_kwargs() -> None: def test_tool_with_kwargs() -> None:

@ -19,6 +19,11 @@ _EXPECTED = [
"ExtractTextTool", "ExtractTextTool",
"FileSearchTool", "FileSearchTool",
"GetElementsTool", "GetElementsTool",
"GmailCreateDraft",
"GmailGetMessage",
"GmailGetThread",
"GmailSearch",
"GmailSendMessage",
"GooglePlacesTool", "GooglePlacesTool",
"GoogleSearchResults", "GoogleSearchResults",
"GoogleSearchRun", "GoogleSearchRun",

@ -8,11 +8,12 @@ from typing import List, Type
import pytest import pytest
from langchain.tools.base import BaseTool from langchain.tools.base import BaseTool
from langchain.tools.gmail.base import GmailBaseTool
from langchain.tools.playwright.base import BaseBrowserTool from langchain.tools.playwright.base import BaseBrowserTool
def get_non_abstract_subclasses(cls: Type[BaseTool]) -> List[Type[BaseTool]]: def get_non_abstract_subclasses(cls: Type[BaseTool]) -> List[Type[BaseTool]]:
to_skip = {BaseBrowserTool} # Abstract but not recognized to_skip = {BaseBrowserTool, GmailBaseTool} # Abstract but not recognized
subclasses = [] subclasses = []
for subclass in cls.__subclasses__(): for subclass in cls.__subclasses__():
if ( if (

Loading…
Cancel
Save