├── 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 |
--------------------------------------------------------------------------------