├── eaia ├── __init__.py ├── main │ ├── __init__.py │ ├── config.py │ ├── fewshot.py │ ├── rewrite.py │ ├── triage.py │ ├── find_meeting_time.py │ ├── config.yaml │ ├── graph.py │ ├── draft_response.py │ └── human_inbox.py ├── cron_graph.py ├── schemas.py ├── reflection_graphs.py └── gmail.py ├── .env.example ├── CONTRIBUTING.md ├── langgraph.json ├── scripts ├── run_single.py ├── setup_cron.py ├── setup_gmail.py └── run_ingest.py ├── LICENSE ├── pyproject.toml ├── .gitignore └── README.md /eaia/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eaia/main/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LANGSMITH_API_KEY=... 2 | OPENAI_API_KEY=... 3 | GMAIL_SECRET=... 4 | GMAIL_TOKEN=... 5 | ANTHROPIC_API_KEY=... -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Since this powers Harrison's real email assistant, only contributions that improve that experience for Harrison will be merged in. 2 | 3 | Harrison is very supportive of variants of this email assistant popping up. Add in other LLMs - great! Add in other email providers - great! 4 | 5 | He just doesn't want to maintain them as he's already very busy. 6 | 7 | Therefore, he will only merge in PRs that improve his experience, but he heartily invites you to fork and modify this repo to your hearts content! 8 | -------------------------------------------------------------------------------- /langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "python_version": "3.11", 3 | "dependencies": [ 4 | "." 5 | ], 6 | "graphs": { 7 | "main": "./eaia/main/graph.py:graph", 8 | "cron": "./eaia/cron_graph.py:graph", 9 | "general_reflection_graph": "./eaia/reflection_graphs.py:general_reflection_graph", 10 | "multi_reflection_graph": "./eaia/reflection_graphs.py:multi_reflection_graph" 11 | }, 12 | "store": { 13 | "index": { 14 | "embed": "openai:text-embedding-3-small", 15 | "dims": 1536 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /eaia/main/config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from pathlib import Path 3 | 4 | _ROOT = Path(__file__).absolute().parent 5 | 6 | 7 | def get_config(config: dict): 8 | # This loads things either ALL from configurable, or 9 | # all from the config.yaml 10 | # This is done intentionally to enforce an "all or nothing" configuration 11 | if "email" in config["configurable"]: 12 | return config["configurable"] 13 | else: 14 | with open(_ROOT.joinpath("config.yaml")) as stream: 15 | return yaml.safe_load(stream) 16 | -------------------------------------------------------------------------------- /scripts/run_single.py: -------------------------------------------------------------------------------- 1 | """Script for testing a single run through an agent.""" 2 | 3 | import asyncio 4 | from langgraph_sdk import get_client 5 | import uuid 6 | import hashlib 7 | 8 | from eaia.schemas import EmailData 9 | 10 | 11 | async def main(): 12 | client = get_client(url="http://127.0.0.1:2024") 13 | 14 | email: EmailData = { 15 | "from_email": "Test", 16 | "to_email": "test@gmail.com", 17 | "subject": "Re: Hello!", 18 | "page_content": "Test", 19 | "id": "123", 20 | "thread_id": "123", 21 | "send_time": "2024-12-26T13:13:41-08:00", 22 | } 23 | 24 | thread_id = str( 25 | uuid.UUID(hex=hashlib.md5(email["thread_id"].encode("UTF-8")).hexdigest()) 26 | ) 27 | try: 28 | await client.threads.delete(thread_id) 29 | except: 30 | pass 31 | await client.threads.create(thread_id=thread_id) 32 | await client.runs.create( 33 | thread_id, 34 | "main", 35 | input={"email": email}, 36 | multitask_strategy="rollback", 37 | ) 38 | 39 | 40 | if __name__ == "__main__": 41 | asyncio.run(main()) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 LangChain 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "eaia" 3 | version = "0.1.0" 4 | description = "" 5 | authors = [] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.12" 10 | langgraph = "^0.4.5" 11 | langgraph-checkpoint = "^2.0.0" 12 | langchain = "^0.3.9" 13 | langchain-openai = "^0.2" 14 | langchain-anthropic = "^0.3" 15 | google-api-python-client = "^2.128.0" 16 | langchain-auth = "^0.1.2" 17 | langgraph-sdk = "^0.2" 18 | langsmith = "^0.3.45" 19 | pytz = "*" 20 | pyyaml = "*" 21 | python-dateutil = "^2.9.0.post0" 22 | python-dotenv = "^1.0.1" 23 | langgraph-cli = {extras = ["inmem"], version = "^0.3.6"} 24 | langgraph-api = "^0.2.134" 25 | 26 | [tool.setuptools.packages.find] 27 | where = ["src"] 28 | 29 | [tool.setuptools.package-data] 30 | customer_support = ["*.txt", "*.rst"] 31 | 32 | [tool.poetry.group.dev.dependencies] 33 | ipykernel = "^6.29.4" 34 | pytest-asyncio = "^0.23.6" 35 | pytest = "^8.2.0" 36 | pytest-watch = "^4.2.0" 37 | vcrpy = "^6.0.1" 38 | langgraph-cli = "^0.3.6" 39 | 40 | [build-system] 41 | requires = ["poetry-core"] 42 | build-backend = "poetry.core.masonry.api" 43 | 44 | [tool.pytest.ini_options] 45 | asyncio_mode = "auto" 46 | -------------------------------------------------------------------------------- /scripts/setup_cron.py: -------------------------------------------------------------------------------- 1 | """Set up a cron job that runs every 10 minutes to check for emails""" 2 | import argparse 3 | import asyncio 4 | from typing import Optional 5 | from langgraph_sdk import get_client 6 | 7 | 8 | async def main( 9 | url: Optional[str] = None, 10 | minutes_since: int = 60, 11 | ): 12 | if url is None: 13 | client = get_client(url="http://127.0.0.1:2024") 14 | else: 15 | client = get_client( 16 | url=url 17 | ) 18 | await client.crons.create("cron", schedule="*/10 * * * *", input={"minutes_since": minutes_since}) 19 | 20 | 21 | 22 | if __name__ == "__main__": 23 | parser = argparse.ArgumentParser() 24 | parser.add_argument( 25 | "--url", 26 | type=str, 27 | default=None, 28 | help="URL to run against", 29 | ) 30 | parser.add_argument( 31 | "--minutes-since", 32 | type=int, 33 | default=60, 34 | help="Only process emails that are less than this many minutes old.", 35 | ) 36 | 37 | args = parser.parse_args() 38 | asyncio.run( 39 | main( 40 | url=args.url, 41 | minutes_since=args.minutes_since, 42 | ) 43 | ) -------------------------------------------------------------------------------- /eaia/main/fewshot.py: -------------------------------------------------------------------------------- 1 | """Fetches few shot examples for triage step.""" 2 | 3 | from langgraph.store.base import BaseStore 4 | from eaia.schemas import EmailData 5 | 6 | 7 | template = """Email Subject: {subject} 8 | Email From: {from_email} 9 | Email To: {to_email} 10 | Email Content: 11 | ``` 12 | {content} 13 | ``` 14 | > Triage Result: {result}""" 15 | 16 | 17 | def format_similar_examples_store(examples): 18 | strs = ["Here are some previous examples:"] 19 | for eg in examples: 20 | strs.append( 21 | template.format( 22 | subject=eg.value["input"]["subject"], 23 | to_email=eg.value["input"]["to_email"], 24 | from_email=eg.value["input"]["from_email"], 25 | content=eg.value["input"]["page_content"][:400], 26 | result=eg.value["triage"], 27 | ) 28 | ) 29 | return "\n\n------------\n\n".join(strs) 30 | 31 | 32 | async def get_few_shot_examples(email: EmailData, store: BaseStore, config): 33 | namespace = ( 34 | config["configurable"].get("assistant_id", "default"), 35 | "triage_examples", 36 | ) 37 | result = await store.asearch(namespace, query=str({"input": email}), limit=5) 38 | if result is None: 39 | return "" 40 | return format_similar_examples_store(result) 41 | -------------------------------------------------------------------------------- /eaia/cron_graph.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | from eaia.gmail import fetch_group_emails 3 | from langgraph_sdk import get_client 4 | import httpx 5 | import uuid 6 | import hashlib 7 | from langgraph.graph import StateGraph, START, END 8 | from eaia.main.config import get_config 9 | 10 | client = get_client() 11 | 12 | 13 | class JobKickoff(TypedDict): 14 | minutes_since: int 15 | 16 | 17 | async def main(state: JobKickoff, config): 18 | minutes_since: int = state["minutes_since"] 19 | email = get_config(config)["email"] 20 | 21 | async for email in fetch_group_emails(email, minutes_since=minutes_since): 22 | thread_id = str( 23 | uuid.UUID(hex=hashlib.md5(email["thread_id"].encode("UTF-8")).hexdigest()) 24 | ) 25 | try: 26 | thread_info = await client.threads.get(thread_id) 27 | except httpx.HTTPStatusError as e: 28 | if "user_respond" in email: 29 | continue 30 | if e.response.status_code == 404: 31 | thread_info = await client.threads.create(thread_id=thread_id) 32 | else: 33 | raise e 34 | if "user_respond" in email: 35 | await client.threads.update_state(thread_id, None, as_node="__end__") 36 | continue 37 | recent_email = thread_info["metadata"].get("email_id") 38 | if recent_email == email["id"]: 39 | break 40 | await client.threads.update(thread_id, metadata={"email_id": email["id"]}) 41 | 42 | await client.runs.create( 43 | thread_id, 44 | "main", 45 | input={"email": email}, 46 | multitask_strategy="rollback", 47 | ) 48 | 49 | 50 | graph = StateGraph(JobKickoff) 51 | graph.add_node(main) 52 | graph.add_edge(START, "main") 53 | graph.add_edge("main", END) 54 | graph = graph.compile() 55 | -------------------------------------------------------------------------------- /eaia/main/rewrite.py: -------------------------------------------------------------------------------- 1 | """Agent responsible for rewriting the email in a better tone.""" 2 | 3 | from langchain_openai import ChatOpenAI 4 | 5 | from eaia.schemas import State, ReWriteEmail 6 | 7 | from eaia.main.config import get_config 8 | 9 | 10 | rewrite_prompt = """You job is to rewrite an email draft to sound more like {name}. 11 | 12 | {name}'s assistant just drafted an email. It is factually correct, but it may not sound like {name}. \ 13 | Your job is to rewrite the email keeping the information the same (do not add anything that is made up!) \ 14 | but adjusting the tone. 15 | 16 | {instructions} 17 | 18 | Here is the assistant's current draft: 19 | 20 | 21 | {draft} 22 | 23 | 24 | Here is the email thread: 25 | 26 | From: {author} 27 | To: {to} 28 | Subject: {subject} 29 | 30 | {email_thread}""" 31 | 32 | 33 | async def rewrite(state: State, config, store): 34 | model = config["configurable"].get("model", "gpt-4o") 35 | llm = ChatOpenAI(model=model, temperature=0) 36 | prev_message = state["messages"][-1] 37 | draft = prev_message.tool_calls[0]["args"]["content"] 38 | namespace = (config["configurable"].get("assistant_id", "default"),) 39 | result = await store.aget(namespace, "rewrite_instructions") 40 | prompt_config = get_config(config) 41 | if result and "data" in result.value: 42 | _prompt = result.value["data"] 43 | else: 44 | await store.aput( 45 | namespace, 46 | "rewrite_instructions", 47 | {"data": prompt_config["rewrite_preferences"]}, 48 | ) 49 | _prompt = prompt_config["rewrite_preferences"] 50 | input_message = rewrite_prompt.format( 51 | email_thread=state["email"]["page_content"], 52 | author=state["email"]["from_email"], 53 | subject=state["email"]["subject"], 54 | to=state["email"]["to_email"], 55 | draft=draft, 56 | instructions=_prompt, 57 | name=prompt_config["name"], 58 | ) 59 | model = llm.with_structured_output(ReWriteEmail).bind( 60 | tool_choice={"type": "function", "function": {"name": "ReWriteEmail"}} 61 | ) 62 | response = await model.ainvoke(input_message) 63 | tool_calls = [ 64 | { 65 | "id": prev_message.tool_calls[0]["id"], 66 | "name": prev_message.tool_calls[0]["name"], 67 | "args": { 68 | **prev_message.tool_calls[0]["args"], 69 | **{"content": response.rewritten_content}, 70 | }, 71 | } 72 | ] 73 | prev_message = { 74 | "role": "assistant", 75 | "id": prev_message.id, 76 | "content": prev_message.content, 77 | "tool_calls": tool_calls, 78 | } 79 | return {"messages": [prev_message]} 80 | -------------------------------------------------------------------------------- /eaia/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated, List, Literal 2 | from langgraph.graph.message import AnyMessage 3 | from pydantic import BaseModel, Field 4 | from typing_extensions import TypedDict 5 | 6 | 7 | from langgraph.graph import add_messages 8 | 9 | 10 | class EmailData(TypedDict): 11 | id: str 12 | thread_id: str 13 | from_email: str 14 | subject: str 15 | page_content: str 16 | send_time: str 17 | to_email: str 18 | 19 | 20 | class RespondTo(BaseModel): 21 | logic: str = Field( 22 | description="logic on WHY the response choice is the way it is", default="" 23 | ) 24 | response: Literal["no", "email", "notify", "question"] = "no" 25 | 26 | 27 | class ResponseEmailDraft(BaseModel): 28 | """Draft of an email to send as a response.""" 29 | 30 | content: str 31 | new_recipients: List[str] 32 | 33 | 34 | class NewEmailDraft(BaseModel): 35 | """Draft of a new email to send.""" 36 | 37 | content: str 38 | recipients: List[str] 39 | 40 | 41 | class ReWriteEmail(BaseModel): 42 | """Logic for rewriting an email""" 43 | 44 | tone_logic: str = Field( 45 | description="Logic for what the tone of the rewritten email should be" 46 | ) 47 | rewritten_content: str = Field(description="Content rewritten with the new tone") 48 | 49 | 50 | class Question(BaseModel): 51 | """Question to ask user.""" 52 | 53 | content: str 54 | 55 | 56 | class Ignore(BaseModel): 57 | """Call this to ignore the email. Only call this if user has said to do so.""" 58 | 59 | ignore: bool 60 | 61 | 62 | class MeetingAssistant(BaseModel): 63 | """Call this to have user's meeting assistant look at it.""" 64 | 65 | call: bool 66 | 67 | 68 | class SendCalendarInvite(BaseModel): 69 | """Call this to send a calendar invite.""" 70 | 71 | emails: List[str] = Field( 72 | description="List of emails to send the calendar invitation for. Do NOT make any emails up!" 73 | ) 74 | title: str = Field(description="Name of the meeting") 75 | start_time: str = Field( 76 | description="Start time for the meeting, should be in `2024-07-01T14:00:00` format" 77 | ) 78 | end_time: str = Field( 79 | description="End time for the meeting, should be in `2024-07-01T14:00:00` format" 80 | ) 81 | 82 | 83 | # Needed to mix Pydantic with TypedDict 84 | def convert_obj(o, m): 85 | if isinstance(m, dict): 86 | return RespondTo(**m) 87 | else: 88 | return m 89 | 90 | 91 | class State(TypedDict): 92 | email: EmailData 93 | triage: Annotated[RespondTo, convert_obj] 94 | messages: Annotated[List[AnyMessage], add_messages] 95 | 96 | 97 | email_template = """From: {author} 98 | To: {to} 99 | Subject: {subject} 100 | 101 | {email_thread}""" 102 | -------------------------------------------------------------------------------- /eaia/main/triage.py: -------------------------------------------------------------------------------- 1 | """Agent responsible for triaging the email, can either ignore it, try to respond, or notify user.""" 2 | 3 | from langchain_core.runnables import RunnableConfig 4 | from langchain_openai import ChatOpenAI 5 | from langchain_core.messages import RemoveMessage 6 | from langgraph.store.base import BaseStore 7 | 8 | from eaia.schemas import ( 9 | State, 10 | RespondTo, 11 | ) 12 | from eaia.main.fewshot import get_few_shot_examples 13 | from eaia.main.config import get_config 14 | 15 | 16 | triage_prompt = """You are {full_name}'s executive assistant. You are a top-notch executive assistant who cares about {name} performing as well as possible. 17 | 18 | {background}. 19 | 20 | {name} gets lots of emails. Your job is to categorize the below email to see whether is it worth responding to. 21 | 22 | Emails that are not worth responding to: 23 | {triage_no} 24 | 25 | Emails that are worth responding to: 26 | {triage_email} 27 | 28 | There are also other things that {name} should know about, but don't require an email response. For these, you should notify {name} (using the `notify` response). Examples of this include: 29 | {triage_notify} 30 | 31 | For emails not worth responding to, respond `no`. For something where {name} should respond over email, respond `email`. If it's important to notify {name}, but no email is required, respond `notify`. \ 32 | 33 | If unsure, opt to `notify` {name} - you will learn from this in the future. 34 | 35 | {fewshotexamples} 36 | 37 | Please determine how to handle the below email thread: 38 | 39 | From: {author} 40 | To: {to} 41 | Subject: {subject} 42 | 43 | {email_thread}""" 44 | 45 | 46 | async def triage_input(state: State, config: RunnableConfig, store: BaseStore): 47 | model = config["configurable"].get("model", "gpt-4o") 48 | llm = ChatOpenAI(model=model, temperature=0) 49 | examples = await get_few_shot_examples(state["email"], store, config) 50 | prompt_config = get_config(config) 51 | input_message = triage_prompt.format( 52 | email_thread=state["email"]["page_content"], 53 | author=state["email"]["from_email"], 54 | to=state["email"].get("to_email", ""), 55 | subject=state["email"]["subject"], 56 | fewshotexamples=examples, 57 | name=prompt_config["name"], 58 | full_name=prompt_config["full_name"], 59 | background=prompt_config["background"], 60 | triage_no=prompt_config["triage_no"], 61 | triage_email=prompt_config["triage_email"], 62 | triage_notify=prompt_config["triage_notify"], 63 | ) 64 | model = llm.with_structured_output(RespondTo).bind( 65 | tool_choice={"type": "function", "function": {"name": "RespondTo"}} 66 | ) 67 | response = await model.ainvoke(input_message) 68 | if len(state["messages"]) > 0: 69 | delete_messages = [RemoveMessage(id=m.id) for m in state["messages"]] 70 | return {"triage": response, "messages": delete_messages} 71 | else: 72 | return {"triage": response} 73 | -------------------------------------------------------------------------------- /eaia/main/find_meeting_time.py: -------------------------------------------------------------------------------- 1 | """Agent responsible for managing calendar and finding meeting time.""" 2 | 3 | from datetime import datetime 4 | 5 | from langchain.agents.react.agent import create_react_agent 6 | from langchain_core.messages import ToolMessage 7 | from langchain_core.runnables import RunnableConfig 8 | from langchain_openai import ChatOpenAI 9 | 10 | from eaia.gmail import get_events_for_days 11 | from eaia.schemas import State 12 | 13 | from eaia.main.config import get_config 14 | 15 | meeting_prompts = """You are {full_name}'s executive assistant. You are a top-notch executive assistant who cares about {name} performing as well as possible. 16 | 17 | The below email thread has been flagged as requesting time to meet. Your SOLE purpose is to survey {name}'s calendar and schedule meetings for {name}. 18 | 19 | If the email is suggesting some specific times, then check if {name} is available then. 20 | 21 | If the emails asks for time, use the tool to find valid times to meet (always suggest them in {tz}). 22 | 23 | If they express preferences in their email thread, try to abide by those. Do not suggest times they have already said won't work. 24 | 25 | Try to send available spots in as big of chunks as possible. For example, if {name} has 1pm-3pm open, send: 26 | 27 | ``` 28 | 1pm-3pm 29 | ``` 30 | 31 | NOT 32 | 33 | ``` 34 | 1-1:30pm 35 | 1:30-2pm 36 | 2-2:30pm 37 | 2:30-3pm 38 | ``` 39 | 40 | Do not send time slots less than 15 minutes in length. 41 | 42 | Your response should be extremely high density. You should not respond directly to the email, but rather just say factually whether {name} is free, and what time slots. Do not give any extra commentary. Examples of good responses include: 43 | 44 | 45 | 46 | Example 1: 47 | 48 | > {name} is free 9:30-10 49 | 50 | Example 2: 51 | 52 | > {name} is not free then. But he is free at 10:30 53 | 54 | 55 | 56 | The current data is {current_date} 57 | 58 | Here is the email thread: 59 | 60 | From: {author} 61 | Subject: {subject} 62 | 63 | {email_thread}""" 64 | 65 | 66 | async def find_meeting_time(state: State, config: RunnableConfig): 67 | """Write an email to a customer.""" 68 | model = config["configurable"].get("model", "gpt-4o") 69 | llm = ChatOpenAI(model=model, temperature=0) 70 | agent = create_react_agent(llm, [get_events_for_days]) 71 | current_date = datetime.now() 72 | prompt_config = get_config(config) 73 | input_message = meeting_prompts.format( 74 | email_thread=state["email"]["page_content"], 75 | author=state["email"]["from_email"], 76 | subject=state["email"]["subject"], 77 | current_date=current_date.strftime("%A %B %d, %Y"), 78 | name=prompt_config["name"], 79 | full_name=prompt_config["full_name"], 80 | tz=prompt_config["timezone"], 81 | ) 82 | messages = state.get("messages") or [] 83 | # we do this because theres currently a tool call just for routing 84 | messages = messages[:-1] 85 | result = await agent.ainvoke( 86 | {"messages": [{"role": "user", "content": input_message}] + messages} 87 | ) 88 | prediction = state["messages"][-1] 89 | tool_call = prediction.tool_calls[0] 90 | return { 91 | "messages": [ 92 | ToolMessage( 93 | content=result["messages"][-1].content, tool_call_id=tool_call["id"] 94 | ) 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /scripts/setup_gmail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Setup script to create Google OAuth provider using langchain auth-client. 4 | This only needs to be run once to configure the OAuth provider. 5 | """ 6 | import asyncio 7 | import os 8 | import json 9 | from pathlib import Path 10 | from langchain_auth import Client 11 | 12 | 13 | async def setup_google_oauth_provider(): 14 | """Create Google OAuth provider configuration.""" 15 | 16 | # Get LangSmith API key 17 | api_key = os.getenv("LANGSMITH_API_KEY") 18 | if not api_key: 19 | raise ValueError("LANGSMITH_API_KEY environment variable must be set") 20 | 21 | # Look for Google OAuth client secrets 22 | secrets_dir = Path(__file__).parent.parent / "eaia" / ".secrets" 23 | secrets_path = secrets_dir / "secrets.json" 24 | 25 | if not secrets_path.exists(): 26 | print(f"Error: Google OAuth client secrets file not found at {secrets_path}") 27 | print("Please:") 28 | print("1. Follow the Google OAuth setup instructions in the README") 29 | print("2. Download your client secrets JSON file") 30 | print("3. Save it as eaia/.secrets/secrets.json") 31 | return False 32 | 33 | # Load client secrets 34 | with open(secrets_path) as f: 35 | secrets = json.load(f) 36 | 37 | # Extract OAuth configuration from Google client secrets 38 | if "web" in secrets: 39 | oauth_config = secrets["web"] 40 | elif "installed" in secrets: 41 | oauth_config = secrets["installed"] 42 | else: 43 | raise ValueError("Invalid Google client secrets format") 44 | 45 | client_id = oauth_config["client_id"] 46 | client_secret = oauth_config["client_secret"] 47 | 48 | # Create langchain auth client 49 | client = Client(api_key=api_key) 50 | 51 | try: 52 | # Check if Google provider already exists 53 | try: 54 | providers = await client.list_oauth_providers() 55 | existing_google = next((p for p in providers if p.provider_id == "google"), None) 56 | 57 | if existing_google: 58 | print(f"Google OAuth provider already exists: {existing_google}") 59 | print("Setup complete! You can now use the executive assistant.") 60 | return True 61 | except Exception as e: 62 | print(f"Warning: Could not check existing providers: {e}") 63 | 64 | # Create Google OAuth provider 65 | print("Creating Google OAuth provider...") 66 | provider = await client.create_oauth_provider( 67 | provider_id="google", 68 | name="Google", 69 | client_id=client_id, 70 | client_secret=client_secret, 71 | auth_url="https://accounts.google.com/o/oauth2/auth", 72 | token_url="https://oauth2.googleapis.com/token", 73 | ) 74 | 75 | print(f"Successfully created Google OAuth provider: {provider}") 76 | print("Setup complete! You can now use the executive assistant.") 77 | return True 78 | 79 | except Exception as e: 80 | print(f"Error creating Google OAuth provider: {e}") 81 | return False 82 | 83 | finally: 84 | await client.close() 85 | 86 | 87 | if __name__ == "__main__": 88 | success = asyncio.run(setup_google_oauth_provider()) 89 | if not success: 90 | exit(1) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # pycharm 153 | .idea/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # Artifacts from running LangGraph Server locally 159 | .langgraph_api/ 160 | 161 | # Secrets for Google Workspace (Gmail, GCal) 162 | .secrets/ 163 | -------------------------------------------------------------------------------- /scripts/run_ingest.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | from typing import Optional 4 | from eaia.gmail import fetch_group_emails 5 | from eaia.main.config import get_config 6 | from langgraph_sdk import get_client 7 | import httpx 8 | import uuid 9 | import hashlib 10 | 11 | 12 | async def main( 13 | url: Optional[str] = None, 14 | minutes_since: int = 60, 15 | gmail_token: Optional[str] = None, 16 | gmail_secret: Optional[str] = None, 17 | early: bool = True, 18 | rerun: bool = False, 19 | email: Optional[str] = None, 20 | ): 21 | if email is None: 22 | email_address = get_config({"configurable": {}})["email"] 23 | else: 24 | email_address = email 25 | if url is None: 26 | client = get_client(url="http://127.0.0.1:2024") 27 | else: 28 | client = get_client( 29 | url=url 30 | ) 31 | 32 | print(f"📧 Fetching emails for {email_address} from last {minutes_since} minutes...") 33 | 34 | email_count = 0 35 | async for email in fetch_group_emails( 36 | email_address, 37 | minutes_since=minutes_since, 38 | gmail_token=gmail_token, 39 | gmail_secret=gmail_secret, 40 | ): 41 | email_count += 1 42 | print(f"📬 Email {email_count}: {email.get('subject', 'No Subject')} from {email.get('from_email', 'Unknown')}") 43 | 44 | thread_id = str( 45 | uuid.UUID(hex=hashlib.md5(email["thread_id"].encode("UTF-8")).hexdigest()) 46 | ) 47 | try: 48 | thread_info = await client.threads.get(thread_id) 49 | except httpx.HTTPStatusError as e: 50 | if "user_respond" in email: 51 | continue 52 | if e.response.status_code == 404: 53 | thread_info = await client.threads.create(thread_id=thread_id) 54 | else: 55 | raise e 56 | if "user_respond" in email: 57 | await client.threads.update_state(thread_id, None, as_node="__end__") 58 | continue 59 | recent_email = thread_info["metadata"].get("email_id") 60 | if recent_email == email["id"]: 61 | if early: 62 | break 63 | else: 64 | if rerun: 65 | pass 66 | else: 67 | continue 68 | await client.threads.update(thread_id, metadata={"email_id": email["id"]}) 69 | 70 | await client.runs.create( 71 | thread_id, 72 | "main", 73 | input={"email": email}, 74 | multitask_strategy="rollback", 75 | ) 76 | 77 | 78 | if __name__ == "__main__": 79 | parser = argparse.ArgumentParser() 80 | parser.add_argument( 81 | "--url", 82 | type=str, 83 | default=None, 84 | help="URL to run against", 85 | ) 86 | parser.add_argument( 87 | "--early", 88 | type=int, 89 | default=1, 90 | help="whether to break when encountering seen emails", 91 | ) 92 | parser.add_argument( 93 | "--rerun", 94 | type=int, 95 | default=0, 96 | help="whether to rerun all emails", 97 | ) 98 | parser.add_argument( 99 | "--minutes-since", 100 | type=int, 101 | default=60, 102 | help="Only process emails that are less than this many minutes old.", 103 | ) 104 | parser.add_argument( 105 | "--gmail-token", 106 | type=str, 107 | default=None, 108 | help="The token to use in communicating with the Gmail API.", 109 | ) 110 | parser.add_argument( 111 | "--gmail-secret", 112 | type=str, 113 | default=None, 114 | help="The creds to use in communicating with the Gmail API.", 115 | ) 116 | parser.add_argument( 117 | "--email", 118 | type=str, 119 | default=None, 120 | help="The email address to use", 121 | ) 122 | 123 | args = parser.parse_args() 124 | asyncio.run( 125 | main( 126 | url=args.url, 127 | minutes_since=args.minutes_since, 128 | gmail_token=args.gmail_token, 129 | gmail_secret=args.gmail_secret, 130 | early=bool(args.early), 131 | rerun=bool(args.rerun), 132 | email=args.email, 133 | ) 134 | ) 135 | -------------------------------------------------------------------------------- /eaia/main/config.yaml: -------------------------------------------------------------------------------- 1 | email: ... 2 | full_name: Harrison Chase 3 | name: Harrison 4 | background: Harrison is CEO and co-founder of LangChain. LangChain is a startup building tools for developers to build LLM applications. 5 | schedule_preferences: By default, unless specified otherwise, you should make meetings 30 minutes long. 6 | background_preferences: | 7 | LangChain has a product marketer - Linda. For emails where she may be relevant, please loop her in. If possible, just add her to the thread and let her handle any asks (not Harrison). Examples include: being asked to amplify a podcast, blogpost, or other work featuring Harrison or LangChain 8 | response_preferences: 9 | timezone: "PST" 10 | rewrite_preferences: | 11 | Harrison has a few rules for how he likes his emails to be written: 12 | 13 | - He generally likes to match their tone. If they write formally, then so will he. If they write casually, then he should write casual 14 | - If working with an assistant to schedule, then he is usually more formal 15 | - If conversing with someone he seems to know well, then he is more direct and to the fact 16 | - Harrison REALLY does not want anyone to know he is using an assistant, so you should try not to sound too much like an assistant and rather sound like Harrison 17 | - When Harrison is casual, he generally does not include any greetings or sign offs, and just directly says his message 18 | 19 | triage_no: | 20 | - Automated emails from services that are spamming Harrison 21 | - Cold outreach from vendors - this happens a lot as people try to sell Harrison things. He is not interested in these 22 | - Emails where they are asking questions that can best be answered by other people on the thread. \ 23 | Harrison is often on a lot of threads with people from his company (LangChain) but often times he does not need to chime in. \ 24 | The exception to this is if Harrison is the main driver of the conversation. \ 25 | You can usually tell this by whether Harrison was the one who sent the last email 26 | - Generally do not need to see emails from Ramp, Rewatch, Stripe 27 | - Notifications of comments on Google Docs 28 | - Automated calendar invitations 29 | triage_notify: | 30 | - Google docs that were shared with him (do NOT notify him on comments, just net new ones) 31 | - Docusign things that needs to sign. These are using from Docusign and start with "Complete with Docusign". \ 32 | Note: if the Docusign is already signed, you do NOT need to notify him. The way to tell is that those emails start \ 33 | with "Completed: Complete with Docusign". Note the "Completed". Do not notify him if "Completed", only if still needs to be completed. 34 | - Anything that is pretty technically detailed about LangChain. Harrison sometimes gets asked questions about LangChain, \ 35 | while he may not always respond to those he likes getting notified about them 36 | - Emails where there is a clear action item from Harrison based on a previous conversation, like adding people to a slack channel 37 | triage_email: | 38 | - Emails from clients that explicitly ask Harrison a question 39 | - Emails from clients where someone else has scheduled a meeting for Harrison, and Harrison has not already chimed in to express his excitement 40 | - Emails from clients or potential customers where Harrison is the main driver of the conversation 41 | - Emails from other LangChain team members that explicitly ask Harrison a question 42 | - Emails where Harrison has gotten added to a thread with a customer and he hasn't yet said hello 43 | - Emails where Harrison is introducing two people to each other. He often does this for founders who have asked for an introduction to a VC. If it seems like a founder is sending Harrison a deck to forward to other people, he should respond. If Harrison has already introduced the two parties, he should not respond unless they explicitly ask him a question. 44 | - Email from clients where they are trying to set up a time to meet 45 | - Any direct emails from Harrison's lawyers (Goodwin Law) 46 | - Any direct emails related to the LangChain board 47 | - Emails where LangChain is winning an award/being invited to a legitimate event 48 | - Emails where it seems like Harrison has a pre-existing relationship with the sender. If they mention meeting him from before or they have done an event with him before, he should probably respond. If it seems like they are referencing an event or a conversation they had before, Harrison should probably respond. 49 | - Emails from friends - even these don't ask an explicit question, if it seems like something a good friend would respond to, Harrison should do so. 50 | 51 | Reminder - automated calendar invites do NOT count as real emails 52 | memory: true 53 | -------------------------------------------------------------------------------- /eaia/main/graph.py: -------------------------------------------------------------------------------- 1 | """Overall agent.""" 2 | import json 3 | from typing import TypedDict, Literal 4 | from langgraph.graph import END, StateGraph 5 | from langchain_core.messages import HumanMessage 6 | from eaia.main.triage import ( 7 | triage_input, 8 | ) 9 | from eaia.main.draft_response import draft_response 10 | from eaia.main.find_meeting_time import find_meeting_time 11 | from eaia.main.rewrite import rewrite 12 | from eaia.main.config import get_config 13 | from langchain_core.messages import ToolMessage 14 | from eaia.main.human_inbox import ( 15 | send_message, 16 | send_email_draft, 17 | notify, 18 | send_cal_invite, 19 | ) 20 | from eaia.gmail import ( 21 | send_email, 22 | mark_as_read, 23 | send_calendar_invite, 24 | ) 25 | from eaia.schemas import ( 26 | State, 27 | ) 28 | 29 | 30 | def route_after_triage( 31 | state: State, 32 | ) -> Literal["draft_response", "mark_as_read_node", "notify"]: 33 | if state["triage"].response == "email": 34 | return "draft_response" 35 | elif state["triage"].response == "no": 36 | return "mark_as_read_node" 37 | elif state["triage"].response == "notify": 38 | return "notify" 39 | elif state["triage"].response == "question": 40 | return "draft_response" 41 | else: 42 | raise ValueError 43 | 44 | 45 | def take_action( 46 | state: State, 47 | ) -> Literal[ 48 | "send_message", 49 | "rewrite", 50 | "mark_as_read_node", 51 | "find_meeting_time", 52 | "send_cal_invite", 53 | "bad_tool_name", 54 | ]: 55 | prediction = state["messages"][-1] 56 | if len(prediction.tool_calls) != 1: 57 | raise ValueError 58 | tool_call = prediction.tool_calls[0] 59 | if tool_call["name"] == "Question": 60 | return "send_message" 61 | elif tool_call["name"] == "ResponseEmailDraft": 62 | return "rewrite" 63 | elif tool_call["name"] == "Ignore": 64 | return "mark_as_read_node" 65 | elif tool_call["name"] == "MeetingAssistant": 66 | return "find_meeting_time" 67 | elif tool_call["name"] == "SendCalendarInvite": 68 | return "send_cal_invite" 69 | else: 70 | return "bad_tool_name" 71 | 72 | 73 | def bad_tool_name(state: State): 74 | tool_call = state["messages"][-1].tool_calls[0] 75 | message = f"Could not find tool with name `{tool_call['name']}`. Make sure you are calling one of the allowed tools!" 76 | last_message = state["messages"][-1] 77 | last_message.tool_calls[0]["name"] = last_message.tool_calls[0]["name"].replace( 78 | ":", "" 79 | ) 80 | return { 81 | "messages": [ 82 | last_message, 83 | ToolMessage(content=message, tool_call_id=tool_call["id"]), 84 | ] 85 | } 86 | 87 | 88 | def enter_after_human( 89 | state, 90 | ) -> Literal[ 91 | "mark_as_read_node", "draft_response", "send_email_node", "send_cal_invite_node" 92 | ]: 93 | messages = state.get("messages") or [] 94 | if len(messages) == 0: 95 | if state["triage"].response == "notify": 96 | return "mark_as_read_node" 97 | raise ValueError 98 | else: 99 | if isinstance(messages[-1], (ToolMessage, HumanMessage)): 100 | return "draft_response" 101 | else: 102 | execute = messages[-1].tool_calls[0] 103 | if execute["name"] == "ResponseEmailDraft": 104 | return "send_email_node" 105 | elif execute["name"] == "SendCalendarInvite": 106 | return "send_cal_invite_node" 107 | elif execute["name"] == "Ignore": 108 | return "mark_as_read_node" 109 | elif execute["name"] == "Question": 110 | return "draft_response" 111 | else: 112 | raise ValueError 113 | 114 | 115 | def send_cal_invite_node(state, config): 116 | tool_call = state["messages"][-1].tool_calls[0] 117 | _args = tool_call["args"] 118 | email = get_config(config)["email"] 119 | try: 120 | send_calendar_invite( 121 | _args["emails"], 122 | _args["title"], 123 | _args["start_time"], 124 | _args["end_time"], 125 | email, 126 | ) 127 | message = "Sent calendar invite!" 128 | except Exception as e: 129 | message = f"Got the following error when sending a calendar invite: {e}" 130 | return {"messages": [ToolMessage(content=message, tool_call_id=tool_call["id"])]} 131 | 132 | 133 | def send_email_node(state, config): 134 | tool_call = state["messages"][-1].tool_calls[0] 135 | _args = tool_call["args"] 136 | email = get_config(config)["email"] 137 | new_receipients = _args["new_recipients"] 138 | if isinstance(new_receipients, str): 139 | new_receipients = json.loads(new_receipients) 140 | send_email( 141 | state["email"]["id"], 142 | _args["content"], 143 | email, 144 | addn_receipients=new_receipients, 145 | ) 146 | 147 | 148 | def mark_as_read_node(state, config): 149 | email = get_config(config)["email"] 150 | mark_as_read(state["email"]["id"], email) 151 | 152 | 153 | def human_node(state: State): 154 | pass 155 | 156 | 157 | class ConfigSchema(TypedDict): 158 | db_id: int 159 | model: str 160 | 161 | 162 | graph_builder = StateGraph(State, config_schema=ConfigSchema) 163 | graph_builder.add_node(human_node) 164 | graph_builder.add_node(triage_input) 165 | graph_builder.add_node(draft_response) 166 | graph_builder.add_node(send_message) 167 | graph_builder.add_node(rewrite) 168 | graph_builder.add_node(mark_as_read_node) 169 | graph_builder.add_node(send_email_draft) 170 | graph_builder.add_node(send_email_node) 171 | graph_builder.add_node(bad_tool_name) 172 | graph_builder.add_node(notify) 173 | graph_builder.add_node(send_cal_invite_node) 174 | graph_builder.add_node(send_cal_invite) 175 | graph_builder.add_conditional_edges("triage_input", route_after_triage) 176 | graph_builder.set_entry_point("triage_input") 177 | graph_builder.add_conditional_edges("draft_response", take_action) 178 | graph_builder.add_edge("send_message", "human_node") 179 | graph_builder.add_edge("send_cal_invite", "human_node") 180 | graph_builder.add_node(find_meeting_time) 181 | graph_builder.add_edge("find_meeting_time", "draft_response") 182 | graph_builder.add_edge("bad_tool_name", "draft_response") 183 | graph_builder.add_edge("send_cal_invite_node", "draft_response") 184 | graph_builder.add_edge("send_email_node", "mark_as_read_node") 185 | graph_builder.add_edge("rewrite", "send_email_draft") 186 | graph_builder.add_edge("send_email_draft", "human_node") 187 | graph_builder.add_edge("mark_as_read_node", END) 188 | graph_builder.add_edge("notify", "human_node") 189 | graph_builder.add_conditional_edges("human_node", enter_after_human) 190 | graph = graph_builder.compile() 191 | -------------------------------------------------------------------------------- /eaia/main/draft_response.py: -------------------------------------------------------------------------------- 1 | """Core agent responsible for drafting email.""" 2 | 3 | from langchain_core.runnables import RunnableConfig 4 | from langchain_openai import ChatOpenAI 5 | from langgraph.store.base import BaseStore 6 | 7 | from eaia.schemas import ( 8 | State, 9 | NewEmailDraft, 10 | ResponseEmailDraft, 11 | Question, 12 | MeetingAssistant, 13 | SendCalendarInvite, 14 | Ignore, 15 | email_template, 16 | ) 17 | from eaia.main.config import get_config 18 | 19 | EMAIL_WRITING_INSTRUCTIONS = """You are {full_name}'s executive assistant. You are a top-notch executive assistant who cares about {name} performing as well as possible. 20 | 21 | {background} 22 | 23 | {name} gets lots of emails. This has been determined to be an email that is worth {name} responding to. 24 | 25 | Your job is to help {name} respond. You can do this in a few ways. 26 | 27 | # Using the `Question` tool 28 | 29 | First, get all required information to respond. You can use the Question tool to ask {name} for information if you do not know it. 30 | 31 | When drafting emails (either to response on thread or , if you do not have all the information needed to respond in the most appropriate way, call the `Question` tool until you have that information. Do not put placeholders for names or emails or information - get that directly from {name}! 32 | You can get this information by calling `Question`. Again - do not, under any circumstances, draft an email with placeholders or you will get fired. 33 | 34 | If people ask {name} if he can attend some event or meet with them, do not agree to do so unless he has explicitly okayed it! 35 | 36 | Remember, if you don't have enough information to respond, you can ask {name} for more information. Use the `Question` tool for this. 37 | Never just make things up! So if you do not know something, or don't know what {name} would prefer, don't hesitate to ask him. 38 | Never use the Question tool to ask {name} when they are free - instead, just ask the MeetingAssistant 39 | 40 | # Using the `ResponseEmailDraft` tool 41 | 42 | Next, if you have enough information to respond, you can draft an email for {name}. Use the `ResponseEmailDraft` tool for this. 43 | 44 | ALWAYS draft emails as if they are coming from {name}. Never draft them as "{name}'s assistant" or someone else. 45 | 46 | When adding new recipients - only do that if {name} explicitly asks for it and you know their emails. If you don't know the right emails to add in, then ask {name}. You do NOT need to add in people who are already on the email! Do NOT make up emails. 47 | 48 | {response_preferences} 49 | 50 | # Using the `SendCalendarInvite` tool 51 | 52 | Sometimes you will want to schedule a calendar event. You can do this with the `SendCalendarInvite` tool. 53 | If you are sure that {name} would want to schedule a meeting, and you know that {name}'s calendar is free, you can schedule a meeting by calling the `SendCalendarInvite` tool. {name} trusts you to pick good times for meetings. You shouldn't ask {name} for what meeting times are preferred, but you should make sure he wants to meet. 54 | 55 | {schedule_preferences} 56 | 57 | # Using the `NewEmailDraft` tool 58 | 59 | Sometimes you will need to start a new email thread. If you have all the necessary information for this, use the `NewEmailDraft` tool for this. 60 | 61 | If {name} asks someone if it's okay to introduce them, and they respond yes, you should draft a new email with that introduction. 62 | 63 | # Using the `MeetingAssistant` tool 64 | 65 | If the email is from a legitimate person and is working to schedule a meeting, call the MeetingAssistant to get a response from a specialist! 66 | You should not ask {name} for meeting times (unless the Meeting Assistant is unable to find any). 67 | If they ask for times from {name}, first ask the MeetingAssistant by calling the `MeetingAssistant` tool. 68 | Note that you should only call this if working to schedule a meeting - if a meeting has already been scheduled, and they are referencing it, no need to call this. 69 | 70 | # Background information: information you may find helpful when responding to emails or deciding what to do. 71 | 72 | {random_preferences}""" 73 | draft_prompt = """{instructions} 74 | 75 | Remember to call a tool correctly! Use the specified names exactly - not add `functions::` to the start. Pass all required arguments. 76 | 77 | Here is the email thread. Note that this is the full email thread. Pay special attention to the most recent email. 78 | 79 | {email}""" 80 | 81 | 82 | async def draft_response(state: State, config: RunnableConfig, store: BaseStore): 83 | """Write an email to a customer.""" 84 | model = config["configurable"].get("model", "gpt-4o") 85 | llm = ChatOpenAI( 86 | model=model, 87 | temperature=0, 88 | parallel_tool_calls=False, 89 | tool_choice="required", 90 | ) 91 | tools = [ 92 | NewEmailDraft, 93 | ResponseEmailDraft, 94 | Question, 95 | MeetingAssistant, 96 | SendCalendarInvite, 97 | ] 98 | messages = state.get("messages") or [] 99 | if len(messages) > 0: 100 | tools.append(Ignore) 101 | prompt_config = get_config(config) 102 | namespace = (config["configurable"].get("assistant_id", "default"),) 103 | key = "schedule_preferences" 104 | result = await store.aget(namespace, key) 105 | if result and "data" in result.value: 106 | schedule_preferences = result.value["data"] 107 | else: 108 | await store.aput(namespace, key, {"data": prompt_config["schedule_preferences"]}) 109 | schedule_preferences = prompt_config["schedule_preferences"] 110 | key = "random_preferences" 111 | result = await store.aget(namespace, key) 112 | if result and "data" in result.value: 113 | random_preferences = result.value["data"] 114 | else: 115 | await store.aput( 116 | namespace, key, {"data": prompt_config["background_preferences"]} 117 | ) 118 | random_preferences = prompt_config["background_preferences"] 119 | key = "response_preferences" 120 | result = await store.aget(namespace, key) 121 | if result and "data" in result.value: 122 | response_preferences = result.value["data"] 123 | else: 124 | await store.aput(namespace, key, {"data": prompt_config["response_preferences"]}) 125 | response_preferences = prompt_config["response_preferences"] 126 | _prompt = EMAIL_WRITING_INSTRUCTIONS.format( 127 | schedule_preferences=schedule_preferences, 128 | random_preferences=random_preferences, 129 | response_preferences=response_preferences, 130 | name=prompt_config["name"], 131 | full_name=prompt_config["full_name"], 132 | background=prompt_config["background"], 133 | ) 134 | input_message = draft_prompt.format( 135 | instructions=_prompt, 136 | email=email_template.format( 137 | email_thread=state["email"]["page_content"], 138 | author=state["email"]["from_email"], 139 | subject=state["email"]["subject"], 140 | to=state["email"].get("to_email", ""), 141 | ), 142 | ) 143 | 144 | model = llm.bind_tools(tools) 145 | messages = [{"role": "user", "content": input_message}] + messages 146 | i = 0 147 | while i < 5: 148 | response = await model.ainvoke(messages) 149 | if len(response.tool_calls) != 1: 150 | i += 1 151 | messages += [{"role": "user", "content": "Please call a valid tool call."}] 152 | else: 153 | break 154 | return {"draft": response, "messages": [response]} 155 | -------------------------------------------------------------------------------- /eaia/reflection_graphs.py: -------------------------------------------------------------------------------- 1 | from langgraph.store.base import BaseStore 2 | from langchain_openai import ChatOpenAI 3 | from langchain_anthropic import ChatAnthropic 4 | from typing import TypedDict, Optional 5 | from langgraph.graph import StateGraph, START, END, MessagesState 6 | from langgraph.types import Command, Send 7 | 8 | TONE_INSTRUCTIONS = "Only update the prompt to include instructions on the **style and tone and format** of the response. Do NOT update the prompt to include anything about the actual content - only the style and tone and format. The user sometimes responds differently to different types of people - take that into account, but don't be too specific." 9 | RESPONSE_INSTRUCTIONS = "Only update the prompt to include instructions on the **content** of the response. Do NOT update the prompt to include anything about the tone or style or format of the response." 10 | SCHEDULE_INSTRUCTIONS = "Only update the prompt to include instructions on how to send calendar invites - eg when to send them, what title should be, length, time of day, etc" 11 | BACKGROUND_INSTRUCTIONS = "Only update the prompt to include pieces of information that are relevant to being the user's assistant. Do not update the instructions to include anything about the tone of emails sent, when to send calendar invites. Examples of good things to include are (but are not limited to): people's emails, addresses, etc." 12 | 13 | 14 | def get_trajectory_clean(messages): 15 | response = [] 16 | for m in messages: 17 | response.append(m.pretty_repr()) 18 | return "\n".join(response) 19 | 20 | 21 | class ReflectionState(MessagesState): 22 | feedback: Optional[str] 23 | prompt_key: str 24 | assistant_key: str 25 | instructions: str 26 | 27 | 28 | class GeneralResponse(TypedDict): 29 | logic: str 30 | update_prompt: bool 31 | new_prompt: str 32 | 33 | 34 | general_reflection_prompt = """You are helping an AI agent improve. You can do this by changing their system prompt. 35 | 36 | These is their current prompt: 37 | 38 | {current_prompt} 39 | 40 | 41 | Here was the agent's trajectory: 42 | 43 | {trajectory} 44 | 45 | 46 | Here is the user's feedback: 47 | 48 | 49 | {feedback} 50 | 51 | 52 | Here are instructions for updating the agent's prompt: 53 | 54 | 55 | {instructions} 56 | 57 | 58 | 59 | Based on this, return an updated prompt 60 | 61 | You should return the full prompt, so if there's anything from before that you want to include, make sure to do that. Feel free to override or change anything that seems irrelevant. You do not need to update the prompt - if you don't want to, just return `update_prompt = False` and an empty string for new prompt.""" 62 | 63 | 64 | async def update_general(state: ReflectionState, config, store: BaseStore): 65 | reflection_model = ChatOpenAI(model="o1", disable_streaming=True) 66 | # reflection_model = ChatAnthropic(model="claude-3-5-sonnet-latest") 67 | namespace = (state["assistant_key"],) 68 | key = state["prompt_key"] 69 | result = await store.aget(namespace, key) 70 | 71 | async def get_output(messages, current_prompt, feedback, instructions): 72 | trajectory = get_trajectory_clean(messages) 73 | prompt = general_reflection_prompt.format( 74 | current_prompt=current_prompt, 75 | trajectory=trajectory, 76 | feedback=feedback, 77 | instructions=instructions, 78 | ) 79 | _output = await reflection_model.with_structured_output( 80 | GeneralResponse, method="json_schema" 81 | ).ainvoke(prompt) 82 | return _output 83 | 84 | output = await get_output( 85 | state["messages"], 86 | result.value["data"], 87 | state["feedback"], 88 | state["instructions"], 89 | ) 90 | if output["update_prompt"]: 91 | await store.aput( 92 | namespace, key, {"data": output["new_prompt"]}, index=False 93 | ) 94 | 95 | 96 | 97 | general_reflection_graph = StateGraph(ReflectionState) 98 | general_reflection_graph.add_node(update_general) 99 | general_reflection_graph.add_edge(START, "update_general") 100 | general_reflection_graph.add_edge("update_general", END) 101 | general_reflection_graph = general_reflection_graph.compile() 102 | 103 | MEMORY_TO_UPDATE = { 104 | "tone": "Instruction about the tone and style and format of the resulting email. Update this if you learn new information about the tone in which the user likes to respond that may be relevant in future emails.", 105 | "background": "Background information about the user. Update this if you learn new information about the user that may be relevant in future emails", 106 | "email": "Instructions about the type of content to be included in email. Update this if you learn new information about how the user likes to respond to emails (not the tone, and not information about the user, but specifically about how or when they like to respond to emails) that may be relevant in the future.", 107 | "calendar": "Instructions about how to send calendar invites (including title, length, time, etc). Update this if you learn new information about how the user likes to schedule events that may be relevant in future emails.", 108 | } 109 | MEMORY_TO_UPDATE_KEYS = { 110 | "tone": "rewrite_instructions", 111 | "background": "random_preferences", 112 | "email": "response_preferences", 113 | "calendar": "schedule_preferences", 114 | } 115 | MEMORY_TO_UPDATE_INSTRUCTIONS = { 116 | "tone": TONE_INSTRUCTIONS, 117 | "background": BACKGROUND_INSTRUCTIONS, 118 | "email": RESPONSE_INSTRUCTIONS, 119 | "calendar": SCHEDULE_INSTRUCTIONS, 120 | } 121 | 122 | CHOOSE_MEMORY_PROMPT = """You are helping an AI agent improve. You can do this by changing prompts. 123 | 124 | Here was the agent's trajectory: 125 | 126 | {trajectory} 127 | 128 | 129 | Here is the user's feedback: 130 | 131 | 132 | {feedback} 133 | 134 | 135 | These are the different types of prompts that you can update in order to change their behavior: 136 | 137 | 138 | {types_of_prompts} 139 | 140 | 141 | Please choose the types of prompts that are worth updating based on this trajectory + feedback. Only do this if the feedback seems like it has info relevant to the prompt. You will update the prompts themselves in a separate step. You do not have to update any memory types if you don't want to! Just leave it empty.""" 142 | 143 | 144 | class MultiMemoryInput(MessagesState): 145 | prompt_types: list[str] 146 | feedback: str 147 | assistant_key: str 148 | 149 | 150 | async def determine_what_to_update(state: MultiMemoryInput): 151 | reflection_model = ChatOpenAI(model="gpt-4o", disable_streaming=True) 152 | reflection_model = ChatAnthropic(model="claude-3-5-sonnet-latest") 153 | trajectory = get_trajectory_clean(state["messages"]) 154 | types_of_prompts = "\n".join( 155 | [f"`{p_type}`: {MEMORY_TO_UPDATE[p_type]}" for p_type in state["prompt_types"]] 156 | ) 157 | prompt = CHOOSE_MEMORY_PROMPT.format( 158 | trajectory=trajectory, 159 | feedback=state["feedback"], 160 | types_of_prompts=types_of_prompts, 161 | ) 162 | 163 | class MemoryToUpdate(TypedDict): 164 | memory_types_to_update: list[str] 165 | 166 | response = reflection_model.with_structured_output(MemoryToUpdate).invoke(prompt) 167 | sends = [] 168 | for t in response["memory_types_to_update"]: 169 | _state = { 170 | "messages": state["messages"], 171 | "feedback": state["feedback"], 172 | "prompt_key": MEMORY_TO_UPDATE_KEYS[t], 173 | "assistant_key": state["assistant_key"], 174 | "instructions": MEMORY_TO_UPDATE_INSTRUCTIONS[t], 175 | } 176 | send = Send("reflection", _state) 177 | sends.append(send) 178 | return Command(goto=sends) 179 | 180 | 181 | # Done so this can run in parallel 182 | async def call_reflection(state: ReflectionState): 183 | await general_reflection_graph.ainvoke(state) 184 | 185 | 186 | multi_reflection_graph = StateGraph(MultiMemoryInput) 187 | multi_reflection_graph.add_node(determine_what_to_update) 188 | multi_reflection_graph.add_node("reflection", call_reflection) 189 | multi_reflection_graph.add_edge(START, "determine_what_to_update") 190 | multi_reflection_graph = multi_reflection_graph.compile() 191 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Executive AI Assistant 2 | 3 | Executive AI Assistant (EAIA) is an AI agent that attempts to do the job of an Executive Assistant (EA). 4 | 5 | For a hosted version of EAIA, see documentation [here](https://mirror-feeling-d80.notion.site/How-to-hire-and-communicate-with-an-AI-Email-Assistant-177808527b17803289cad9e323d0be89?pvs=4). 6 | 7 | Table of contents 8 | 9 | - [General Setup](#general-setup) 10 | - [Env](#env) 11 | - [Credentials](#env) 12 | - [Configuration](#configuration) 13 | - [Run locally](#run-locally) 14 | - [Setup EAIA](#set-up-eaia-locally) 15 | - [Ingest emails](#ingest-emails-locally) 16 | - [Connect to Agent Inbox](#set-up-agent-inbox-with-local-eaia) 17 | - [Use Agent Inbox](#use-agent-inbox) 18 | - [Run in production (LangGraph Platform)](#run-in-production--langgraph-platform) 19 | - [Setup EAIA on LangGraph Platform](#set-up-eaia-on-langgraph-platform) 20 | - [Ingest manually](#ingest-manually) 21 | - [Set up cron job](#set-up-cron-job) 22 | 23 | ## General Setup 24 | 25 | ### Env 26 | 27 | 1. Fork and clone this repo. Note: make sure to fork it first, as in order to deploy this you will need your own repo. 28 | 2. Create a Python virtualenv and activate it (e.g. `pyenv virtualenv 3.11.1 eaia`, `pyenv activate eaia`) 29 | 3. Run `pip install -e .` to install dependencies and the package 30 | 31 | ### Set up your credentials 32 | 33 | 1. Export OpenAI API key (`export OPENAI_API_KEY=...`) 34 | 2. Export Anthropic API key (`export ANTHROPIC_API_KEY=...`) 35 | 3. Set up Google OAuth 36 | 1. [Enable the API](https://developers.google.com/gmail/api/quickstart/python#enable_the_api) 37 | - Enable Gmail API if not already by clicking the blue button `Enable the API` 38 | 2. [Authorize credentials for a desktop application](https://developers.google.com/gmail/api/quickstart/python#authorize_credentials_for_a_desktop_application) 39 | 40 | > Note: If you're using a personal email (non-Google Workspace), select "External" as the User Type in the OAuth consent screen. With "External" selected, you must add your email as a test user in the Google Cloud Console under "OAuth consent screen" > "Test users" to avoid the "App has not completed verification" error. The "Internal" option only works for Google Workspace accounts. 41 | 42 | 5. Download the client secret. After that, run these commands: 43 | 6. `mkdir eaia/.secrets` - This will create a folder for secrets 44 | 7. `mv ${PATH-TO-CLIENT-SECRET.JSON} eaia/.secrets/secrets.json` - This will move the client secret you just created to that secrets folder 45 | 8. `python scripts/setup_gmail.py` - This will create the Google OAuth provider using LangChain Auth and handle the initial authentication flow. 46 | 47 | **Authentication Flow**: EAIA uses LangChain Auth for OAuth management. The setup script creates a Google OAuth provider that handles token storage and refresh automatically. When you first run the application, you'll be prompted to complete OAuth authentication if needed. 48 | 49 | ### Configuration 50 | 51 | The configuration for EAIA can be found in `eaia/main/config.yaml`. Every key in there is required. These are the configuration options: 52 | 53 | - `email`: Email to monitor and send emails as. This should match the credentials you loaded above. 54 | - `full_name`: Full name of user 55 | - `name`: First name of user 56 | - `background`: Basic info on who the user is 57 | - `timezone`: Default timezone where the user is 58 | - `schedule_preferences`: Any preferences for how calendar meetings are scheduled. E.g. length, name of meetings, etc 59 | - `background_preferences`: Any background information that may be needed when responding to emails. E.g. coworkers to loop in, etc. 60 | - `response_preferences`: Any preferences for what information to include in emails. E.g. whether to send calendly links, etc. 61 | - `rewrite_preferences`: Any preferences for the tone of your emails 62 | - `triage_no`: Guidelines for when emails should be ignored 63 | - `triage_notify`: Guidelines for when user should be notified of emails (but EAIA should not attempt to draft a response) 64 | - `triage_email`: Guidelines for when EAIA should try to draft a response to an email 65 | 66 | ## Run locally 67 | 68 | You can run EAIA locally. 69 | This is useful for testing it out, but when wanting to use it for real you will need to have it always running (to run the cron job to check for emails). 70 | See [this section](#run-in-production--langgraph-platform) for instructions on how to run in production (on LangGraph Platform) 71 | 72 | ### Set up EAIA locally 73 | 74 | 1. Install development server `pip install -U "langgraph-cli[inmem]"` 75 | 2. Run development server `langgraph dev` 76 | 77 | ### Ingest Emails Locally 78 | 79 | Let's now kick off an ingest job to ingest some emails and run them through our local EAIA. 80 | 81 | Leave the `langgraph dev` command running, and open a new terminal. From there, get back into this directory and virtual environment. To kick off an ingest job, run: 82 | 83 | ```shell 84 | python scripts/run_ingest.py --minutes-since 120 --rerun 1 --early 0 85 | ``` 86 | 87 | This will ingest all emails in the last 120 minutes (`--minutes-since`). It will NOT break early if it sees an email it already saw (`--early 0`) and it will 88 | rerun ones it has seen before (`--rerun 1`). It will run against the local instance we have running. 89 | 90 | ### Set up Agent Inbox with Local EAIA 91 | 92 | After we have [run it locally](#run-locally), we can interract with any results. 93 | 94 | 1. Go to [Agent Inbox](https://dev.agentinbox.ai/) 95 | 2. Connect this to your locally running EAIA agent: 96 | 1. Click into `Settings` 97 | 2. Input your LangSmith API key. 98 | 3. Click `Add Inbox` 99 | 1. Set `Assistant/Graph ID` to `main` 100 | 2. Set `Deployment URL` to `http://127.0.0.1:2024` 101 | 3. Give it a name like `Local EAIA` 102 | 4. Press `Submit` 103 | 104 | You can now interract with EAIA in the Agent Inbox. 105 | 106 | ## Run in production (LangGraph Platform) 107 | 108 | These instructions will go over how to run EAIA in LangGraph Platform. 109 | You will need a LangSmith Plus account to be able to access [LangGraph Platform](https://docs.langchain.com/langgraph-platform) 110 | 111 | ### Set up EAIA on LangGraph Platform 112 | 113 | 1. Make sure you have a LangSmith Plus account 114 | 2. Run the local setup first to create the Google OAuth provider (`python scripts/setup_gmail.py`) 115 | 3. Navigate to the deployments page in LangSmith 116 | 4. Click `New Deployment` 117 | 5. Connect it to your GitHub repo containing this code. 118 | 6. Give it a name like `Executive-AI-Assistant` 119 | 7. Add the following environment variables 120 | 1. `OPENAI_API_KEY` 121 | 2. `ANTHROPIC_API_KEY` 122 | 8. Click `Submit` and watch your EAIA deploy 123 | 124 | ### Ingest manually 125 | 126 | Let's now kick off a manual ingest job to ingest some emails and run them through our LangGraph Platform EAIA. 127 | 128 | First, get your `LANGGRAPH_DEPLOYMENT_URL` 129 | 130 | To kick off an ingest job, run: 131 | 132 | ```shell 133 | python scripts/run_ingest.py --minutes-since 120 --rerun 1 --early 0 --url ${LANGGRAPH_DEPLOYMENT_URL} 134 | ``` 135 | 136 | This will ingest all emails in the last 120 minutes (`--minutes-since`). It will NOT break early if it sees an email it already saw (`--early 0`) and it will 137 | rerun ones it has seen before (`--rerun 1`). It will run against the prod instance we have running (`--url ${LANGGRAPH_DEPLOYMENT_URL}`) 138 | 139 | ### Set up Agent Inbox with LangGraph Platform EAIA 140 | 141 | After we have [deployed it](#set-up-eaia-on-langgraph-platform), we can interract with any results. 142 | 143 | 1. Go to [Agent Inbox](https://dev.agentinbox.ai/) 144 | 2. Connect this to your locally running EAIA agent: 145 | 1. Click into `Settings` 146 | 2. Click `Add Inbox` 147 | 1. Set `Assistant/Graph ID` to `main` 148 | 2. Set `Deployment URL` to your deployment URL 149 | 3. Give it a name like `Prod EAIA` 150 | 4. Press `Submit` 151 | 152 | ### Set up cron job 153 | 154 | You probably don't want to manually run ingest all the time. Using LangGraph Platform, you can easily set up a cron job 155 | that runs on some schedule to check for new emails. You can set this up with: 156 | 157 | ```shell 158 | python scripts/setup_cron.py --url ${LANGGRAPH_DEPLOYMENT_URL} 159 | ``` 160 | 161 | ## Advanced Options 162 | 163 | If you want to control more of EAIA besides what the configuration allows, you can modify parts of the code base. 164 | 165 | **Reflection Logic** 166 | To control the prompts used for reflection (e.g. to populate memory) you can edit `eaia/reflection_graphs.py` 167 | 168 | **Triage Logic** 169 | To control the logic used for triaging emails you can edit `eaia/main/triage.py` 170 | 171 | **Calendar Logic** 172 | To control the logic used for looking at available times on the calendar you can edit `eaia/main/find_meeting_time.py` 173 | 174 | **Tone & Style Logic** 175 | To control the logic used for the tone and style of emails you can edit `eaia/main/rewrite.py` 176 | 177 | **Email Draft Logic** 178 | To control the logic used for drafting emails you can edit `eaia/main/draft_response.py` 179 | -------------------------------------------------------------------------------- /eaia/main/human_inbox.py: -------------------------------------------------------------------------------- 1 | """Parts of the graph that require human input.""" 2 | 3 | import uuid 4 | 5 | from langsmith import traceable 6 | from eaia.schemas import State, email_template 7 | from langgraph.types import interrupt 8 | from langgraph.store.base import BaseStore 9 | from typing import TypedDict, Literal, Union, Optional 10 | from langgraph_sdk import get_client 11 | from eaia.main.config import get_config 12 | 13 | LGC = get_client() 14 | 15 | 16 | class HumanInterruptConfig(TypedDict): 17 | allow_ignore: bool 18 | allow_respond: bool 19 | allow_edit: bool 20 | allow_accept: bool 21 | 22 | 23 | class ActionRequest(TypedDict): 24 | action: str 25 | args: dict 26 | 27 | 28 | class HumanInterrupt(TypedDict): 29 | action_request: ActionRequest 30 | config: HumanInterruptConfig 31 | description: Optional[str] 32 | 33 | 34 | class HumanResponse(TypedDict): 35 | type: Literal["accept", "ignore", "response", "edit"] 36 | args: Union[None, str, ActionRequest] 37 | 38 | 39 | TEMPLATE = """# {subject} 40 | 41 | [Click here to view the email]({url}) 42 | 43 | **To**: {to} 44 | **From**: {_from} 45 | 46 | {page_content} 47 | """ 48 | 49 | 50 | def _generate_email_markdown(state: State): 51 | contents = state["email"] 52 | return TEMPLATE.format( 53 | subject=contents["subject"], 54 | url=f"https://mail.google.com/mail/u/0/#inbox/{contents['id']}", 55 | to=contents["to_email"], 56 | _from=contents["from_email"], 57 | page_content=contents["page_content"], 58 | ) 59 | 60 | 61 | async def save_email(state: State, config, store: BaseStore, status: str): 62 | namespace = ( 63 | config["configurable"].get("assistant_id", "default"), 64 | "triage_examples", 65 | ) 66 | key = state["email"]["id"] 67 | response = await store.aget(namespace, key) 68 | if response is None: 69 | data = {"input": state["email"], "triage": status} 70 | await store.aput(namespace, str(uuid.uuid4()), data) 71 | 72 | 73 | @traceable 74 | async def send_message(state: State, config, store): 75 | prompt_config = get_config(config) 76 | memory = prompt_config["memory"] 77 | user = prompt_config['name'] 78 | tool_call = state["messages"][-1].tool_calls[0] 79 | request: HumanInterrupt = { 80 | "action_request": {"action": tool_call["name"], "args": tool_call["args"]}, 81 | "config": { 82 | "allow_ignore": True, 83 | "allow_respond": True, 84 | "allow_edit": False, 85 | "allow_accept": False, 86 | }, 87 | "description": _generate_email_markdown(state), 88 | } 89 | response = interrupt([request])[0] 90 | _email_template = email_template.format( 91 | email_thread=state["email"]["page_content"], 92 | author=state["email"]["from_email"], 93 | subject=state["email"]["subject"], 94 | to=state["email"].get("to_email", ""), 95 | ) 96 | if response["type"] == "response": 97 | msg = { 98 | "type": "tool", 99 | "name": tool_call["name"], 100 | "content": response["args"], 101 | "tool_call_id": tool_call["id"], 102 | } 103 | if memory: 104 | await save_email(state, config, store, "email") 105 | rewrite_state = { 106 | "messages": [ 107 | { 108 | "role": "user", 109 | "content": f"Draft a response to this email:\n\n{_email_template}", 110 | } 111 | ] 112 | + state["messages"], 113 | "feedback": f"{user} responded in this way: {response['args']}", 114 | "prompt_types": ["background"], 115 | "assistant_key": config["configurable"].get("assistant_id", "default"), 116 | } 117 | await LGC.runs.create(None, "multi_reflection_graph", input=rewrite_state) 118 | elif response["type"] == "ignore": 119 | msg = { 120 | "role": "assistant", 121 | "content": "", 122 | "id": state["messages"][-1].id, 123 | "tool_calls": [ 124 | { 125 | "id": tool_call["id"], 126 | "name": "Ignore", 127 | "args": {"ignore": True}, 128 | } 129 | ], 130 | } 131 | if memory: 132 | await save_email(state, config, store, "no") 133 | else: 134 | raise ValueError(f"Unexpected response: {response}") 135 | 136 | return {"messages": [msg]} 137 | 138 | 139 | @traceable 140 | async def send_email_draft(state: State, config, store): 141 | prompt_config = get_config(config) 142 | memory = prompt_config["memory"] 143 | user = prompt_config['name'] 144 | tool_call = state["messages"][-1].tool_calls[0] 145 | request: HumanInterrupt = { 146 | "action_request": {"action": tool_call["name"], "args": tool_call["args"]}, 147 | "config": { 148 | "allow_ignore": True, 149 | "allow_respond": True, 150 | "allow_edit": True, 151 | "allow_accept": True, 152 | }, 153 | "description": _generate_email_markdown(state), 154 | } 155 | response = interrupt([request])[0] 156 | _email_template = email_template.format( 157 | email_thread=state["email"]["page_content"], 158 | author=state["email"]["from_email"], 159 | subject=state["email"]["subject"], 160 | to=state["email"].get("to_email", ""), 161 | ) 162 | if response["type"] == "response": 163 | msg = { 164 | "type": "tool", 165 | "name": tool_call["name"], 166 | "content": f"Error, {user} interrupted and gave this feedback: {response['args']}", 167 | "tool_call_id": tool_call["id"], 168 | } 169 | if memory: 170 | await save_email(state, config, store, "email") 171 | rewrite_state = { 172 | "messages": [ 173 | { 174 | "role": "user", 175 | "content": f"Draft a response to this email:\n\n{_email_template}", 176 | } 177 | ] 178 | + state["messages"], 179 | "feedback": f"Error, {user} interrupted and gave this feedback: {response['args']}", 180 | "prompt_types": ["tone", "email", "background", "calendar"], 181 | "assistant_key": config["configurable"].get("assistant_id", "default"), 182 | } 183 | await LGC.runs.create(None, "multi_reflection_graph", input=rewrite_state) 184 | elif response["type"] == "ignore": 185 | msg = { 186 | "role": "assistant", 187 | "content": "", 188 | "id": state["messages"][-1].id, 189 | "tool_calls": [ 190 | { 191 | "id": tool_call["id"], 192 | "name": "Ignore", 193 | "args": {"ignore": True}, 194 | } 195 | ], 196 | } 197 | if memory: 198 | await save_email(state, config, store, "no") 199 | elif response["type"] == "edit": 200 | msg = { 201 | "role": "assistant", 202 | "content": state["messages"][-1].content, 203 | "id": state["messages"][-1].id, 204 | "tool_calls": [ 205 | { 206 | "id": tool_call["id"], 207 | "name": tool_call["name"], 208 | "args": response["args"]["args"], 209 | } 210 | ], 211 | } 212 | if memory: 213 | corrected = response["args"]["args"]["content"] 214 | await save_email(state, config, store, "email") 215 | rewrite_state = { 216 | "messages": [ 217 | { 218 | "role": "user", 219 | "content": f"Draft a response to this email:\n\n{_email_template}", 220 | }, 221 | { 222 | "role": "assistant", 223 | "content": state["messages"][-1].tool_calls[0]["args"]["content"], 224 | }, 225 | ], 226 | "feedback": f"A better response would have been: {corrected}", 227 | "prompt_types": ["tone", "email", "background", "calendar"], 228 | "assistant_key": config["configurable"].get("assistant_id", "default"), 229 | } 230 | await LGC.runs.create(None, "multi_reflection_graph", input=rewrite_state) 231 | elif response["type"] == "accept": 232 | if memory: 233 | await save_email(state, config, store, "email") 234 | return None 235 | else: 236 | raise ValueError(f"Unexpected response: {response}") 237 | return {"messages": [msg]} 238 | 239 | 240 | @traceable 241 | async def notify(state: State, config, store): 242 | prompt_config = get_config(config) 243 | memory = prompt_config["memory"] 244 | user = prompt_config['name'] 245 | request: HumanInterrupt = { 246 | "action_request": {"action": "Notify", "args": {}}, 247 | "config": { 248 | "allow_ignore": True, 249 | "allow_respond": True, 250 | "allow_edit": False, 251 | "allow_accept": False, 252 | }, 253 | "description": _generate_email_markdown(state), 254 | } 255 | response = interrupt([request])[0] 256 | _email_template = email_template.format( 257 | email_thread=state["email"]["page_content"], 258 | author=state["email"]["from_email"], 259 | subject=state["email"]["subject"], 260 | to=state["email"].get("to_email", ""), 261 | ) 262 | if response["type"] == "response": 263 | msg = {"type": "user", "content": response["args"]} 264 | if memory: 265 | await save_email(state, config, store, "email") 266 | rewrite_state = { 267 | "messages": [ 268 | { 269 | "role": "user", 270 | "content": f"Draft a response to this email:\n\n{_email_template}", 271 | } 272 | ] 273 | + state["messages"], 274 | "feedback": f"{user} gave these instructions: {response['args']}", 275 | "prompt_types": ["email", "background", "calendar"], 276 | "assistant_key": config["configurable"].get("assistant_id", "default"), 277 | } 278 | await LGC.runs.create(None, "multi_reflection_graph", input=rewrite_state) 279 | elif response["type"] == "ignore": 280 | msg = { 281 | "role": "assistant", 282 | "content": "", 283 | "id": str(uuid.uuid4()), 284 | "tool_calls": [ 285 | { 286 | "id": "foo", 287 | "name": "Ignore", 288 | "args": {"ignore": True}, 289 | } 290 | ], 291 | } 292 | if memory: 293 | await save_email(state, config, store, "no") 294 | else: 295 | raise ValueError(f"Unexpected response: {response}") 296 | 297 | return {"messages": [msg]} 298 | 299 | 300 | @traceable 301 | async def send_cal_invite(state: State, config, store): 302 | prompt_config = get_config(config) 303 | memory = prompt_config["memory"] 304 | user = prompt_config['name'] 305 | tool_call = state["messages"][-1].tool_calls[0] 306 | request: HumanInterrupt = { 307 | "action_request": {"action": tool_call["name"], "args": tool_call["args"]}, 308 | "config": { 309 | "allow_ignore": True, 310 | "allow_respond": True, 311 | "allow_edit": True, 312 | "allow_accept": True, 313 | }, 314 | "description": _generate_email_markdown(state), 315 | } 316 | response = interrupt([request])[0] 317 | _email_template = email_template.format( 318 | email_thread=state["email"]["page_content"], 319 | author=state["email"]["from_email"], 320 | subject=state["email"]["subject"], 321 | to=state["email"].get("to_email", ""), 322 | ) 323 | if response["type"] == "response": 324 | msg = { 325 | "type": "tool", 326 | "name": tool_call["name"], 327 | "content": f"Error, {user} interrupted and gave this feedback: {response['args']}", 328 | "tool_call_id": tool_call["id"], 329 | } 330 | if memory: 331 | await save_email(state, config, store, "email") 332 | rewrite_state = { 333 | "messages": [ 334 | { 335 | "role": "user", 336 | "content": f"Draft a response to this email:\n\n{_email_template}", 337 | } 338 | ] 339 | + state["messages"], 340 | "feedback": f"{user} interrupted gave these instructions: {response['args']}", 341 | "prompt_types": ["email", "background", "calendar"], 342 | "assistant_key": config["configurable"].get("assistant_id", "default"), 343 | } 344 | await LGC.runs.create(None, "multi_reflection_graph", input=rewrite_state) 345 | elif response["type"] == "ignore": 346 | msg = { 347 | "role": "assistant", 348 | "content": "", 349 | "id": state["messages"][-1].id, 350 | "tool_calls": [ 351 | { 352 | "id": tool_call["id"], 353 | "name": "Ignore", 354 | "args": {"ignore": True}, 355 | } 356 | ], 357 | } 358 | if memory: 359 | await save_email(state, config, store, "no") 360 | elif response["type"] == "edit": 361 | msg = { 362 | "role": "assistant", 363 | "content": state["messages"][-1].content, 364 | "id": state["messages"][-1].id, 365 | "tool_calls": [ 366 | { 367 | "id": tool_call["id"], 368 | "name": tool_call["name"], 369 | "args": response["args"]["args"], 370 | } 371 | ], 372 | } 373 | if memory: 374 | await save_email(state, config, store, "email") 375 | rewrite_state = { 376 | "messages": [ 377 | { 378 | "role": "user", 379 | "content": f"Draft a response to this email:\n\n{_email_template}", 380 | } 381 | ] 382 | + state["messages"], 383 | "feedback": f"{user} interrupted gave these instructions: {response['args']}", 384 | "prompt_types": ["email", "background", "calendar"], 385 | "assistant_key": config["configurable"].get("assistant_id", "default"), 386 | } 387 | await LGC.runs.create(None, "multi_reflection_graph", input=rewrite_state) 388 | elif response["type"] == "accept": 389 | if memory: 390 | await save_email(state, config, store, "email") 391 | return None 392 | else: 393 | raise ValueError(f"Unexpected response: {response}") 394 | 395 | return {"messages": [msg]} 396 | -------------------------------------------------------------------------------- /eaia/gmail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta, time 3 | from pathlib import Path 4 | from typing import Iterable 5 | import pytz 6 | import os 7 | import json 8 | 9 | from dateutil import parser 10 | from google.oauth2.credentials import Credentials 11 | from googleapiclient.discovery import build 12 | import base64 13 | from email.mime.multipart import MIMEMultipart 14 | from email.mime.text import MIMEText 15 | import email.utils 16 | from langchain_auth import Client 17 | 18 | from langchain_core.tools import tool 19 | from langchain_core.pydantic_v1 import BaseModel, Field 20 | 21 | from eaia.schemas import EmailData 22 | 23 | logger = logging.getLogger(__name__) 24 | _SCOPES = [ 25 | "https://www.googleapis.com/auth/gmail.modify", 26 | "https://www.googleapis.com/auth/calendar", 27 | ] 28 | 29 | 30 | async def get_credentials( 31 | user_email: str, 32 | langsmith_api_key: str | None = None 33 | ) -> Credentials: 34 | """Get Google API credentials using langchain auth-client. 35 | 36 | Args: 37 | user_email: User's Gmail email address (used as user_id for auth) 38 | langsmith_api_key: LangSmith API key for auth client 39 | 40 | Returns: 41 | Google OAuth2 credentials 42 | """ 43 | api_key = langsmith_api_key or os.getenv("LANGSMITH_API_KEY") 44 | if not api_key: 45 | raise ValueError("LANGSMITH_API_KEY environment variable must be set") 46 | 47 | client = Client(api_key=api_key) 48 | 49 | try: 50 | # Authenticate with Google using the user's email as user_id 51 | auth_result = await client.authenticate( 52 | provider="google", 53 | scopes=_SCOPES, 54 | user_id=user_email 55 | ) 56 | 57 | if auth_result.needs_auth: 58 | print(f"Please visit: {auth_result.auth_url}") 59 | print("Complete the OAuth flow and then retry.") 60 | 61 | # Wait for completion outside of LangGraph context 62 | completed_result = await client.wait_for_completion( 63 | auth_id=auth_result.auth_id, 64 | timeout=300 65 | ) 66 | token = completed_result.token 67 | else: 68 | token = auth_result.token 69 | 70 | if not token: 71 | raise ValueError("Failed to obtain access token") 72 | 73 | # Create credentials object from the token 74 | # langchain auth-client returns the access token as a string 75 | creds = Credentials( 76 | token=token, 77 | scopes=_SCOPES 78 | ) 79 | 80 | return creds 81 | 82 | finally: 83 | await client.close() 84 | 85 | 86 | def extract_message_part(msg): 87 | """Recursively walk through the email parts to find message body.""" 88 | if msg["mimeType"] == "text/plain": 89 | body_data = msg.get("body", {}).get("data") 90 | if body_data: 91 | return base64.urlsafe_b64decode(body_data).decode("utf-8") 92 | elif msg["mimeType"] == "text/html": 93 | body_data = msg.get("body", {}).get("data") 94 | if body_data: 95 | return base64.urlsafe_b64decode(body_data).decode("utf-8") 96 | if "parts" in msg: 97 | for part in msg["parts"]: 98 | body = extract_message_part(part) 99 | if body: 100 | return body 101 | return "No message body available." 102 | 103 | 104 | def parse_time(send_time: str): 105 | try: 106 | parsed_time = parser.parse(send_time) 107 | return parsed_time 108 | except (ValueError, TypeError) as e: 109 | raise ValueError(f"Error parsing time: {send_time} - {e}") 110 | 111 | 112 | def create_message(sender, to, subject, message_text, thread_id, original_message_id): 113 | message = MIMEMultipart() 114 | message["to"] = ", ".join(to) 115 | message["from"] = sender 116 | message["subject"] = subject 117 | message["In-Reply-To"] = original_message_id 118 | message["References"] = original_message_id 119 | message["Message-ID"] = email.utils.make_msgid() 120 | msg = MIMEText(message_text) 121 | message.attach(msg) 122 | raw = base64.urlsafe_b64encode(message.as_bytes()) 123 | raw = raw.decode() 124 | return {"raw": raw, "threadId": thread_id} 125 | 126 | 127 | def get_recipients( 128 | headers, 129 | email_address, 130 | addn_receipients=None, 131 | ): 132 | recipients = set(addn_receipients or []) 133 | sender = None 134 | for header in headers: 135 | if header["name"].lower() in ["to", "cc"]: 136 | recipients.update(header["value"].replace(" ", "").split(",")) 137 | if header["name"].lower() == "from": 138 | sender = header["value"] 139 | if sender: 140 | recipients.add(sender) # Ensure the original sender is included in the response 141 | for r in list(recipients): 142 | if email_address in r: 143 | recipients.remove(r) 144 | return list(recipients) 145 | 146 | 147 | def send_message(service, user_id, message): 148 | message = service.users().messages().send(userId=user_id, body=message).execute() 149 | return message 150 | 151 | 152 | def send_email( 153 | email_id, 154 | response_text, 155 | email_address, 156 | gmail_token: str | None = None, 157 | gmail_secret: str | None = None, 158 | addn_receipients=None, 159 | ): 160 | import asyncio 161 | creds = asyncio.run(get_credentials(email_address)) 162 | 163 | service = build("gmail", "v1", credentials=creds) 164 | message = service.users().messages().get(userId="me", id=email_id).execute() 165 | 166 | headers = message["payload"]["headers"] 167 | message_id = next( 168 | header["value"] for header in headers if header["name"].lower() == "message-id" 169 | ) 170 | thread_id = message["threadId"] 171 | 172 | # Get recipients and sender 173 | recipients = get_recipients(headers, email_address, addn_receipients) 174 | 175 | # Create the response 176 | subject = next( 177 | header["value"] for header in headers if header["name"].lower() == "subject" 178 | ) 179 | response_subject = subject 180 | response_message = create_message( 181 | "me", recipients, response_subject, response_text, thread_id, message_id 182 | ) 183 | # Send the response 184 | send_message(service, "me", response_message) 185 | 186 | 187 | async def fetch_group_emails( 188 | to_email, 189 | minutes_since: int = 30, 190 | gmail_token: str | None = None, 191 | gmail_secret: str | None = None, 192 | ) -> Iterable[EmailData]: 193 | creds = await get_credentials(to_email) 194 | 195 | service = build("gmail", "v1", credentials=creds) 196 | after = int((datetime.now() - timedelta(minutes=minutes_since)).timestamp()) 197 | 198 | query = f"(to:{to_email} OR from:{to_email}) after:{after}" 199 | messages = [] 200 | nextPageToken = None 201 | # Fetch messages matching the query 202 | while True: 203 | results = ( 204 | service.users() 205 | .messages() 206 | .list(userId="me", q=query, pageToken=nextPageToken) 207 | .execute() 208 | ) 209 | if "messages" in results: 210 | messages.extend(results["messages"]) 211 | nextPageToken = results.get("nextPageToken") 212 | if not nextPageToken: 213 | break 214 | 215 | count = 0 216 | for message in messages: 217 | try: 218 | msg = ( 219 | service.users().messages().get(userId="me", id=message["id"]).execute() 220 | ) 221 | thread_id = msg["threadId"] 222 | payload = msg["payload"] 223 | headers = payload.get("headers") 224 | # Get the thread details 225 | thread = service.users().threads().get(userId="me", id=thread_id).execute() 226 | messages_in_thread = thread["messages"] 227 | # Check the last message in the thread 228 | last_message = messages_in_thread[-1] 229 | last_headers = last_message["payload"]["headers"] 230 | from_header = next( 231 | header["value"] for header in last_headers if header["name"] == "From" 232 | ) 233 | last_from_header = next( 234 | header["value"] 235 | for header in last_message["payload"].get("headers") 236 | if header["name"] == "From" 237 | ) 238 | if to_email in last_from_header: 239 | yield { 240 | "id": message["id"], 241 | "thread_id": message["threadId"], 242 | "user_respond": True, 243 | } 244 | # Check if the last message was from you and if the current message is the last in the thread 245 | if to_email not in from_header and message["id"] == last_message["id"]: 246 | subject = next( 247 | header["value"] for header in headers if header["name"] == "Subject" 248 | ) 249 | from_email = next( 250 | (header["value"] for header in headers if header["name"] == "From"), 251 | "", 252 | ).strip() 253 | _to_email = next( 254 | (header["value"] for header in headers if header["name"] == "To"), 255 | "", 256 | ).strip() 257 | if reply_to := next( 258 | ( 259 | header["value"] 260 | for header in headers 261 | if header["name"] == "Reply-To" 262 | ), 263 | "", 264 | ).strip(): 265 | from_email = reply_to 266 | send_time = next( 267 | header["value"] for header in headers if header["name"] == "Date" 268 | ) 269 | # Only process emails that are less than an hour old 270 | parsed_time = parse_time(send_time) 271 | body = extract_message_part(payload) 272 | yield { 273 | "from_email": from_email, 274 | "to_email": _to_email, 275 | "subject": subject, 276 | "page_content": body, 277 | "id": message["id"], 278 | "thread_id": message["threadId"], 279 | "send_time": parsed_time.isoformat(), 280 | } 281 | count += 1 282 | except Exception: 283 | logger.info(f"Failed on {message}") 284 | 285 | logger.info(f"Found {count} emails.") 286 | 287 | 288 | def mark_as_read( 289 | message_id, 290 | user_email: str, 291 | gmail_token: str | None = None, 292 | gmail_secret: str | None = None, 293 | ): 294 | import asyncio 295 | creds = asyncio.run(get_credentials(user_email)) 296 | 297 | service = build("gmail", "v1", credentials=creds) 298 | service.users().messages().modify( 299 | userId="me", id=message_id, body={"removeLabelIds": ["UNREAD"]} 300 | ).execute() 301 | 302 | 303 | class CalInput(BaseModel): 304 | date_strs: list[str] = Field( 305 | description="The days for which to retrieve events. Each day should be represented by dd-mm-yyyy string." 306 | ) 307 | 308 | 309 | @tool(args_schema=CalInput) 310 | def get_events_for_days(date_strs: list[str]): 311 | """ 312 | Retrieves events for a list of days. If you want to check for multiple days, call this with multiple inputs. 313 | 314 | Input in the format of ['dd-mm-yyyy', 'dd-mm-yyyy'] 315 | 316 | Args: 317 | date_strs: The days for which to retrieve events (dd-mm-yyyy string). 318 | 319 | Returns: availability for those days. 320 | """ 321 | import asyncio 322 | # Note: This function needs user_email from config - will be handled by calling code 323 | from .main.config import get_config 324 | from langchain_core.runnables.config import ensure_config 325 | 326 | config = ensure_config() 327 | user_config = get_config(config) 328 | user_email = user_config["email"] 329 | 330 | creds = asyncio.run(get_credentials(user_email)) 331 | service = build("calendar", "v3", credentials=creds) 332 | results = "" 333 | for date_str in date_strs: 334 | # Convert the date string to a datetime.date object 335 | day = datetime.strptime(date_str, "%d-%m-%Y").date() 336 | 337 | start_of_day = datetime.combine(day, time.min).isoformat() + "Z" 338 | end_of_day = datetime.combine(day, time.max).isoformat() + "Z" 339 | 340 | events_result = ( 341 | service.events() 342 | .list( 343 | calendarId="primary", 344 | timeMin=start_of_day, 345 | timeMax=end_of_day, 346 | singleEvents=True, 347 | orderBy="startTime", 348 | ) 349 | .execute() 350 | ) 351 | events = events_result.get("items", []) 352 | 353 | results += f"***FOR DAY {date_str}***\n\n" + print_events(events) 354 | return results 355 | 356 | 357 | def format_datetime_with_timezone(dt_str, timezone="US/Pacific"): 358 | """ 359 | Formats a datetime string with the specified timezone. 360 | 361 | Args: 362 | dt_str: The datetime string to format. 363 | timezone: The timezone to use for formatting. 364 | 365 | Returns: 366 | A formatted datetime string with the timezone abbreviation. 367 | """ 368 | dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00")) 369 | tz = pytz.timezone(timezone) 370 | dt = dt.astimezone(tz) 371 | return dt.strftime("%Y-%m-%d %I:%M %p %Z") 372 | 373 | 374 | def print_events(events): 375 | """ 376 | Prints the events in a human-readable format. 377 | 378 | Args: 379 | events: List of events to print. 380 | """ 381 | if not events: 382 | return "No events found for this day." 383 | 384 | result = "" 385 | 386 | for event in events: 387 | start = event["start"].get("dateTime", event["start"].get("date")) 388 | end = event["end"].get("dateTime", event["end"].get("date")) 389 | summary = event.get("summary", "No Title") 390 | 391 | if "T" in start: # Only format if it's a datetime 392 | start = format_datetime_with_timezone(start) 393 | end = format_datetime_with_timezone(end) 394 | 395 | result += f"Event: {summary}\n" 396 | result += f"Starts: {start}\n" 397 | result += f"Ends: {end}\n" 398 | result += "-" * 40 + "\n" 399 | return result 400 | 401 | 402 | def send_calendar_invite( 403 | emails, title, start_time, end_time, email_address, timezone="PST" 404 | ): 405 | import asyncio 406 | creds = asyncio.run(get_credentials(email_address)) 407 | service = build("calendar", "v3", credentials=creds) 408 | 409 | # Parse the start and end times 410 | start_datetime = datetime.fromisoformat(start_time) 411 | end_datetime = datetime.fromisoformat(end_time) 412 | emails = list(set(emails + [email_address])) 413 | event = { 414 | "summary": title, 415 | "start": { 416 | "dateTime": start_datetime.isoformat(), 417 | "timeZone": timezone, 418 | }, 419 | "end": { 420 | "dateTime": end_datetime.isoformat(), 421 | "timeZone": timezone, 422 | }, 423 | "attendees": [{"email": email} for email in emails], 424 | "reminders": { 425 | "useDefault": False, 426 | "overrides": [ 427 | {"method": "email", "minutes": 24 * 60}, 428 | {"method": "popup", "minutes": 10}, 429 | ], 430 | }, 431 | "conferenceData": { 432 | "createRequest": { 433 | "requestId": f"{title}-{start_datetime.isoformat()}", 434 | "conferenceSolutionKey": {"type": "hangoutsMeet"}, 435 | } 436 | }, 437 | } 438 | 439 | try: 440 | service.events().insert( 441 | calendarId="primary", 442 | body=event, 443 | sendNotifications=True, 444 | conferenceDataVersion=1, 445 | ).execute() 446 | return True 447 | except Exception as e: 448 | logger.info(f"An error occurred while sending the calendar invite: {e}") 449 | return False 450 | --------------------------------------------------------------------------------