diff --git a/docs/modules/agents/toolkits/examples/gmail.ipynb b/docs/modules/agents/toolkits/examples/gmail.ipynb new file mode 100644 index 00000000..d5bb4f53 --- /dev/null +++ b/docs/modules/agents/toolkits/examples/gmail.ipynb @@ -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=, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=),\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=),\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=, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=),\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=, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=),\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=, return_direct=False, verbose=False, callbacks=None, callback_manager=None, api_resource=)]" + ] + }, + "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 +} diff --git a/docs/modules/utils/examples/gmail.ipynb b/docs/modules/utils/examples/gmail.ipynb new file mode 100644 index 00000000..8c4581f6 --- /dev/null +++ b/docs/modules/utils/examples/gmail.ipynb @@ -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 +} diff --git a/langchain/agents/agent_toolkits/__init__.py b/langchain/agents/agent_toolkits/__init__.py index 95239754..4ceb63ec 100644 --- a/langchain/agents/agent_toolkits/__init__.py +++ b/langchain/agents/agent_toolkits/__init__.py @@ -4,6 +4,7 @@ from langchain.agents.agent_toolkits.csv.base import create_csv_agent from langchain.agents.agent_toolkits.file_management.toolkit import ( FileManagementToolkit, ) +from langchain.agents.agent_toolkits.gmail.toolkit import GmailToolkit 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.toolkit import JsonToolkit @@ -51,6 +52,7 @@ __all__ = [ "create_spark_dataframe_agent", "create_csv_agent", "ZapierToolkit", + "GmailToolkit", "JiraToolkit", "FileManagementToolkit", "PlayWrightBrowserToolkit", diff --git a/langchain/agents/agent_toolkits/gmail/__init__.py b/langchain/agents/agent_toolkits/gmail/__init__.py new file mode 100644 index 00000000..02e7f816 --- /dev/null +++ b/langchain/agents/agent_toolkits/gmail/__init__.py @@ -0,0 +1 @@ +"""Gmail toolkit.""" diff --git a/langchain/agents/agent_toolkits/gmail/toolkit.py b/langchain/agents/agent_toolkits/gmail/toolkit.py new file mode 100644 index 00000000..e95f2e68 --- /dev/null +++ b/langchain/agents/agent_toolkits/gmail/toolkit.py @@ -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), + ] diff --git a/langchain/tools/__init__.py b/langchain/tools/__init__.py index 7afee8cc..87fc281f 100644 --- a/langchain/tools/__init__.py +++ b/langchain/tools/__init__.py @@ -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.read import ReadFileTool 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_search.tool import GoogleSearchResults, GoogleSearchRun from langchain.tools.google_serper.tool import GoogleSerperResults, GoogleSerperRun @@ -56,6 +63,11 @@ __all__ = [ "ExtractTextTool", "FileSearchTool", "GetElementsTool", + "GmailCreateDraft", + "GmailGetMessage", + "GmailGetThread", + "GmailSearch", + "GmailSendMessage", "GooglePlacesTool", "GoogleSearchResults", "GoogleSearchRun", diff --git a/langchain/tools/base.py b/langchain/tools/base.py index 1eff815a..dd311e01 100644 --- a/langchain/tools/base.py +++ b/langchain/tools/base.py @@ -160,16 +160,19 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass): def _parse_input( self, tool_input: Union[str, Dict], - ) -> None: + ) -> Union[str, Dict[str, Any]]: """Convert tool input to pydantic model.""" input_args = self.args_schema if isinstance(tool_input, str): if input_args is not None: key_ = next(iter(input_args.__fields__.keys())) input_args.validate({key_: tool_input}) + return tool_input else: 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() def raise_deprecation(cls, values: Dict) -> Dict: @@ -224,7 +227,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass): **kwargs: Any, ) -> Any: """Run the tool.""" - self._parse_input(tool_input) + parsed_input = self._parse_input(tool_input) if not self.verbose and verbose is not None: verbose_ = verbose else: @@ -241,7 +244,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass): **kwargs, ) try: - tool_args, tool_kwargs = self._to_args_and_kwargs(tool_input) + tool_args, tool_kwargs = self._to_args_and_kwargs(parsed_input) observation = ( self._run(*tool_args, run_manager=run_manager, **tool_kwargs) if new_arg_supported @@ -263,7 +266,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass): **kwargs: Any, ) -> Any: """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: verbose_ = verbose else: @@ -280,7 +283,7 @@ class BaseTool(ABC, BaseModel, metaclass=ToolMetaclass): ) try: # 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 = ( await self._arun(*tool_args, run_manager=run_manager, **tool_kwargs) if new_arg_supported diff --git a/langchain/tools/gmail/__init__.py b/langchain/tools/gmail/__init__.py new file mode 100644 index 00000000..41cf73d8 --- /dev/null +++ b/langchain/tools/gmail/__init__.py @@ -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", +] diff --git a/langchain/tools/gmail/base.py b/langchain/tools/gmail/base.py new file mode 100644 index 00000000..9862e818 --- /dev/null +++ b/langchain/tools/gmail/base.py @@ -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) diff --git a/langchain/tools/gmail/create_draft.py b/langchain/tools/gmail/create_draft.py new file mode 100644 index 00000000..b9bafdcf --- /dev/null +++ b/langchain/tools/gmail/create_draft.py @@ -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.") diff --git a/langchain/tools/gmail/get_message.py b/langchain/tools/gmail/get_message.py new file mode 100644 index 00000000..a83d79a9 --- /dev/null +++ b/langchain/tools/gmail/get_message.py @@ -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 diff --git a/langchain/tools/gmail/get_thread.py b/langchain/tools/gmail/get_thread.py new file mode 100644 index 00000000..1b371c51 --- /dev/null +++ b/langchain/tools/gmail/get_thread.py @@ -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 diff --git a/langchain/tools/gmail/search.py b/langchain/tools/gmail/search.py new file mode 100644 index 00000000..7040b56e --- /dev/null +++ b/langchain/tools/gmail/search.py @@ -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 diff --git a/langchain/tools/gmail/send_message.py b/langchain/tools/gmail/send_message.py new file mode 100644 index 00000000..c8b76979 --- /dev/null +++ b/langchain/tools/gmail/send_message.py @@ -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.") diff --git a/langchain/tools/gmail/utils.py b/langchain/tools/gmail/utils.py new file mode 100644 index 00000000..2c94b43d --- /dev/null +++ b/langchain/tools/gmail/utils.py @@ -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) diff --git a/tests/unit_tests/tools/test_base.py b/tests/unit_tests/tools/test_base.py index dfea4bc3..a5cf9c9f 100644 --- a/tests/unit_tests/tools/test_base.py +++ b/tests/unit_tests/tools/test_base.py @@ -1,5 +1,7 @@ """Test the base tool implementation.""" +import json from datetime import datetime +from enum import Enum from functools import partial 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 structured_tool_input.name == "structured_tool_input" args = {"arg1": 1, "arg2": 0.001, "opt_arg": {"foo": "bar"}} - expected_result = "1, 0.001, {'foo': 'bar'}" 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: @@ -235,6 +236,7 @@ def test_structured_single_str_decorator_no_infer_schema() -> None: @tool(infer_schema=False) def unstructured_tool_input(tool_input: str) -> str: """Return the arguments directly.""" + assert isinstance(tool_input, str) return f"{tool_input}" 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" +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: """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.""" def func(tool_input: str, other_arg: str) -> str: + assert isinstance(tool_input, str) + assert isinstance(other_arg, str) return tool_input + other_arg tool = Tool( @@ -323,24 +361,27 @@ def test_named_tool_decorator() -> None: @tool("search") def search_api(query: str) -> str: """Search the API for the query.""" - return "API result" + assert isinstance(query, str) + return f"API result - {query}" assert isinstance(search_api, BaseTool) assert search_api.name == "search" assert not search_api.return_direct + assert search_api.run({"query": "foo"}) == "API result - foo" def test_named_tool_decorator_return_direct() -> None: """Test functionality when arguments and return direct are provided as input.""" @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.""" return "API result" assert isinstance(search_api, BaseTool) assert search_api.name == "search" assert search_api.return_direct + assert search_api.run({"query": "foo"}) == "API result" def test_unnamed_tool_decorator_return_direct() -> None: @@ -349,11 +390,13 @@ def test_unnamed_tool_decorator_return_direct() -> None: @tool(return_direct=True) def search_api(query: str) -> str: """Search the API for the query.""" + assert isinstance(query, str) return "API result" assert isinstance(search_api, BaseTool) assert search_api.name == "search_api" assert search_api.return_direct + assert search_api.run({"query": "foo"}) == "API result" def test_tool_with_kwargs() -> None: diff --git a/tests/unit_tests/tools/test_public_api.py b/tests/unit_tests/tools/test_public_api.py index 43de10e6..f8ff6842 100644 --- a/tests/unit_tests/tools/test_public_api.py +++ b/tests/unit_tests/tools/test_public_api.py @@ -19,6 +19,11 @@ _EXPECTED = [ "ExtractTextTool", "FileSearchTool", "GetElementsTool", + "GmailCreateDraft", + "GmailGetMessage", + "GmailGetThread", + "GmailSearch", + "GmailSendMessage", "GooglePlacesTool", "GoogleSearchResults", "GoogleSearchRun", diff --git a/tests/unit_tests/tools/test_signatures.py b/tests/unit_tests/tools/test_signatures.py index f23550f6..09e4631d 100644 --- a/tests/unit_tests/tools/test_signatures.py +++ b/tests/unit_tests/tools/test_signatures.py @@ -8,11 +8,12 @@ from typing import List, Type import pytest from langchain.tools.base import BaseTool +from langchain.tools.gmail.base import GmailBaseTool from langchain.tools.playwright.base import BaseBrowserTool 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 = [] for subclass in cls.__subclasses__(): if (