| from dotenv import load_dotenv |
| from langchain_openai import ChatOpenAI |
| from typing import TypedDict, Literal |
| import uuid |
| from IPython.display import Image, display |
|
|
| from datetime import datetime |
| from trustcall import create_extractor |
| from typing import Optional |
| from pydantic import BaseModel, Field |
|
|
| from langchain_core.runnables import RunnableConfig |
| from langchain_core.messages import merge_message_runs, HumanMessage, SystemMessage |
|
|
| from langgraph.checkpoint.memory import MemorySaver |
| from langgraph.graph import StateGraph, MessagesState, END, START |
| from langgraph.store.base import BaseStore |
| from langgraph.store.memory import InMemoryStore |
|
|
|
|
| load_dotenv() |
| model = ChatOpenAI(model="gpt-4.1-mini", temperature=0) |
| |
| class UpdateMemory(TypedDict): |
| """ Decision on what memory type to update """ |
| update_type: Literal['user', 'todo', 'instructions'] |
|
|
| |
| class Profile(BaseModel): |
| """This is the profile of the user you are chatting with""" |
| name: Optional[str] = Field(description="The user's name", default=None) |
| location: Optional[str] = Field(description="The user's location", default=None) |
| job: Optional[str] = Field(description="The user's job", default=None) |
| connections: list[str] = Field( |
| description="Personal connection of the user, such as family members, friends, or coworkers", |
| default_factory=list |
| ) |
| interests: list[str] = Field( |
| description="Interests that the user has", |
| default_factory=list |
| ) |
|
|
| |
| class ToDo(BaseModel): |
| task: str = Field(description="The task to be completed.") |
| time_to_complete: Optional[int] = Field(description="Estimated time to complete the task (minutes).") |
| deadline: Optional[datetime] = Field( |
| description="When the task needs to be completed by (if applicable)", |
| default=None |
| ) |
| solutions: list[str] = Field( |
| description="List of specific, actionable solutions (e.g., specific ideas, service providers, or concrete options relevant to completing the task)", |
| min_items=1, |
| default_factory=list |
| ) |
| status: Literal["not started", "in progress", "done", "archived"] = Field( |
| description="Current status of the task", |
| default="not started" |
| ) |
|
|
| |
| profile_extractor = create_extractor( |
| model, |
| tools=[Profile], |
| tool_choice="Profile", |
| ) |
|
|
| |
| class Spy: |
| def __init__(self): |
| self.called_tools = [] |
|
|
| def __call__(self, run): |
| |
| q = [run] |
| while q: |
| r = q.pop() |
| if r.child_runs: |
| q.extend(r.child_runs) |
| if r.run_type == "chat_model": |
| self.called_tools.append( |
| r.outputs["generations"][0][0]["message"]["kwargs"]["tool_calls"] |
| ) |
|
|
| |
| spy = Spy() |
|
|
| def extract_tool_info(tool_calls, schema_name="Memory"): |
| """Extract information from tool calls for both patches and new memories. |
| |
| Args: |
| tool_calls: List of tool calls from the model |
| schema_name: Name of the schema tool (e.g., "Memory", "ToDo", "Profile") |
| """ |
|
|
| |
| changes = [] |
| |
| for call_group in tool_calls: |
| for call in call_group: |
| if call['name'] == 'PatchDoc': |
| if call['args'].get('patches') and len(call['args']['patches']) > 0: |
| changes.append({ |
| 'type': 'update', |
| 'doc_id': call['args']['json_doc_id'], |
| 'planned_edits': call['args']['planned_edits'], |
| 'value': call['args']['patches'][0]['value'] |
| }) |
| elif call['name'] == schema_name: |
| changes.append({ |
| 'type': 'new', |
| 'value': call['args'] |
| }) |
|
|
| |
| result_parts = [] |
| for change in changes: |
| if change['type'] == 'update': |
| result_parts.append( |
| f"Document {change['doc_id']} updated:\n" |
| f"Plan: {change['planned_edits']}\n" |
| f"Added content: {change['value']}" |
| ) |
| else: |
| result_parts.append( |
| f"New {schema_name} created:\n" |
| f"Content: {change['value']}" |
| ) |
| |
| return "\n\n".join(result_parts) |
|
|
| |
| schema_name = "Memory" |
| changes = extract_tool_info(spy.called_tools, schema_name) |
| print(changes) |
|
|
| |
| MODEL_SYSTEM_MESSAGE = """You are a helpful chatbot. |
| |
| You are designed to be a companion to a user, helping them keep track of their ToDo list. |
| |
| You have a long term memory which keeps track of three things: |
| 1. The user's profile (general information about them) |
| 2. The user's ToDo list |
| 3. General instructions for updating the ToDo list |
| |
| Here is the current User Profile (may be empty if no information has been collected yet): |
| <user_profile> |
| {user_profile} |
| </user_profile> |
| |
| Here is the current ToDo List (may be empty if no tasks have been added yet): |
| <todo> |
| {todo} |
| </todo> |
| |
| Here are the current user-specified preferences for updating the ToDo list (may be empty if no preferences have been specified yet): |
| <instructions> |
| {instructions} |
| </instructions> |
| |
| Here are your instructions for reasoning about the user's messages: |
| |
| 1. Reason carefully about the user's messages as presented below. |
| |
| 2. Decide whether any of the your long-term memory should be updated: |
| - If personal information was provided about the user, update the user's profile by calling UpdateMemory tool with type `user` |
| - If tasks are mentioned, update the ToDo list by calling UpdateMemory tool with type `todo` |
| - If the user has specified preferences for how to update the ToDo list, update the instructions by calling UpdateMemory tool with type `instructions` |
| |
| 3. Tell the user that you have updated your memory, if appropriate: |
| - Do not tell the user you have updated the user's profile |
| - Tell the user them when you update the todo list |
| - Do not tell the user that you have updated instructions |
| |
| 4. Err on the side of updating the todo list. No need to ask for explicit permission. |
| |
| 5. Respond naturally to user user after a tool call was made to save memories, or if no tool call was made.""" |
|
|
| |
| TRUSTCALL_INSTRUCTION = """Reflect on following interaction. |
| |
| Use the provided tools to retain any necessary memories about the user. |
| |
| Use parallel tool calling to handle updates and insertions simultaneously. |
| |
| System Time: {time}""" |
|
|
| |
| CREATE_INSTRUCTIONS = """Reflect on the following interaction. |
| |
| Based on this interaction, update your instructions for how to update ToDo list items. |
| |
| Use any feedback from the user to update how they like to have items added, etc. |
| |
| Your current instructions are: |
| |
| <current_instructions> |
| {current_instructions} |
| </current_instructions>""" |
|
|
| |
| def task_mAIstro(state: MessagesState, config: RunnableConfig, store: BaseStore): |
|
|
| """Load memories from the store and use them to personalize the chatbot's response.""" |
| |
| |
| user_id = config["configurable"]["user_id"] |
|
|
| |
| namespace = ("profile", user_id) |
| memories = store.search(namespace) |
| if memories: |
| user_profile = memories[0].value |
| else: |
| user_profile = None |
|
|
| |
| namespace = ("todo", user_id) |
| memories = store.search(namespace) |
| todo = "\n".join(f"{mem.value}" for mem in memories) |
|
|
| |
| namespace = ("instructions", user_id) |
| memories = store.search(namespace) |
| if memories: |
| instructions = memories[0].value |
| else: |
| instructions = "" |
| |
| system_msg = MODEL_SYSTEM_MESSAGE.format(user_profile=user_profile, todo=todo, instructions=instructions) |
|
|
| |
| response = model.bind_tools([UpdateMemory], parallel_tool_calls=False).invoke([SystemMessage(content=system_msg)]+state["messages"]) |
|
|
| return {"messages": [response]} |
|
|
| def update_profile(state: MessagesState, config: RunnableConfig, store: BaseStore): |
|
|
| """Reflect on the chat history and update the memory collection.""" |
| |
| |
| user_id = config["configurable"]["user_id"] |
|
|
| |
| namespace = ("profile", user_id) |
|
|
| |
| existing_items = store.search(namespace) |
|
|
| |
| tool_name = "Profile" |
| existing_memories = ([(existing_item.key, tool_name, existing_item.value) |
| for existing_item in existing_items] |
| if existing_items |
| else None |
| ) |
|
|
| |
| TRUSTCALL_INSTRUCTION_FORMATTED=TRUSTCALL_INSTRUCTION.format(time=datetime.now().isoformat()) |
| updated_messages=list(merge_message_runs(messages=[SystemMessage(content=TRUSTCALL_INSTRUCTION_FORMATTED)] + state["messages"][:-1])) |
|
|
| |
| result = profile_extractor.invoke({"messages": updated_messages, |
| "existing": existing_memories}) |
|
|
| |
| for r, rmeta in zip(result["responses"], result["response_metadata"]): |
| store.put(namespace, |
| rmeta.get("json_doc_id", str(uuid.uuid4())), |
| r.model_dump(mode="json"), |
| ) |
| tool_calls = state['messages'][-1].tool_calls |
| return {"messages": [{"role": "tool", "content": "updated profile", "tool_call_id":tool_calls[0]['id']}]} |
|
|
| def update_todos(state: MessagesState, config: RunnableConfig, store: BaseStore): |
|
|
| """Reflect on the chat history and update the memory collection.""" |
| |
| |
| user_id = config["configurable"]["user_id"] |
|
|
| |
| namespace = ("todo", user_id) |
|
|
| |
| existing_items = store.search(namespace) |
|
|
| |
| tool_name = "ToDo" |
| existing_memories = ([(existing_item.key, tool_name, existing_item.value) |
| for existing_item in existing_items] |
| if existing_items |
| else None |
| ) |
|
|
| |
| TRUSTCALL_INSTRUCTION_FORMATTED=TRUSTCALL_INSTRUCTION.format(time=datetime.now().isoformat()) |
| updated_messages=list(merge_message_runs(messages=[SystemMessage(content=TRUSTCALL_INSTRUCTION_FORMATTED)] + state["messages"][:-1])) |
|
|
| |
| spy = Spy() |
| |
| |
| todo_extractor = create_extractor( |
| model, |
| tools=[ToDo], |
| tool_choice=tool_name, |
| enable_inserts=True |
| ).with_listeners(on_end=spy) |
|
|
| |
| result = todo_extractor.invoke({"messages": updated_messages, |
| "existing": existing_memories}) |
|
|
| |
| for r, rmeta in zip(result["responses"], result["response_metadata"]): |
| store.put(namespace, |
| rmeta.get("json_doc_id", str(uuid.uuid4())), |
| r.model_dump(mode="json"), |
| ) |
| |
| |
| tool_calls = state['messages'][-1].tool_calls |
|
|
| |
| todo_update_msg = extract_tool_info(spy.called_tools, tool_name) |
| return {"messages": [{"role": "tool", "content": todo_update_msg, "tool_call_id":tool_calls[0]['id']}]} |
|
|
| def update_instructions(state: MessagesState, config: RunnableConfig, store: BaseStore): |
|
|
| """Reflect on the chat history and update the memory collection.""" |
| |
| |
| user_id = config["configurable"]["user_id"] |
| |
| namespace = ("instructions", user_id) |
|
|
| existing_memory = store.get(namespace, "user_instructions") |
| |
| |
| system_msg = CREATE_INSTRUCTIONS.format(current_instructions=existing_memory.value if existing_memory else None) |
| new_memory = model.invoke([SystemMessage(content=system_msg)]+state['messages'][:-1] + [HumanMessage(content="Please update the instructions based on the conversation")]) |
|
|
| |
| key = "user_instructions" |
| store.put(namespace, key, {"memory": new_memory.content}) |
| tool_calls = state['messages'][-1].tool_calls |
| return {"messages": [{"role": "tool", "content": "updated instructions", "tool_call_id":tool_calls[0]['id']}]} |
|
|
| |
| def route_message(state: MessagesState, config: RunnableConfig, store: BaseStore) -> Literal[END, "update_todos", "update_instructions", "update_profile"]: |
|
|
| """Reflect on the memories and chat history to decide whether to update the memory collection.""" |
| message = state['messages'][-1] |
| if len(message.tool_calls) ==0: |
| return END |
| else: |
| tool_call = message.tool_calls[0] |
| if tool_call['args']['update_type'] == "user": |
| return "update_profile" |
| elif tool_call['args']['update_type'] == "todo": |
| return "update_todos" |
| elif tool_call['args']['update_type'] == "instructions": |
| return "update_instructions" |
| else: |
| raise ValueError |
|
|
| |
| builder = StateGraph(MessagesState) |
|
|
| |
| builder.add_node(task_mAIstro) |
| builder.add_node(update_todos) |
| builder.add_node(update_profile) |
| builder.add_node(update_instructions) |
| builder.add_edge(START, "task_mAIstro") |
| builder.add_conditional_edges("task_mAIstro", route_message) |
| builder.add_edge("update_todos", "task_mAIstro") |
| builder.add_edge("update_profile", "task_mAIstro") |
| builder.add_edge("update_instructions", "task_mAIstro") |
|
|
| |
| across_thread_memory = InMemoryStore() |
|
|
| |
| within_thread_memory = MemorySaver() |
|
|
| |
| graph = builder.compile(checkpointer=within_thread_memory, store=across_thread_memory) |
|
|
| with open("agentefinal.png", "wb") as f: |
| f.write(graph.get_graph().draw_mermaid_png()) |
|
|
| |
| |
| config = {"configurable": {"thread_id": "1", "user_id": "Lance"}} |
|
|
| |
| input_messages = [HumanMessage(content="My name is Lance. I live in SF with my wife. I have a 1 year old daughter.")] |
|
|
| print("------------------") |
| print("Mensaje: 1") |
| print("------------------") |
| |
| for chunk in graph.stream({"messages": input_messages}, config, stream_mode="values"): |
| chunk["messages"][-1].pretty_print() |
|
|
| |
| input_messages = [HumanMessage(content="My wife asked me to book swim lessons for the baby.")] |
|
|
| print("------------------") |
| print("Mensaje: 2") |
| print("------------------") |
| |
| for chunk in graph.stream({"messages": input_messages}, config, stream_mode="values"): |
| chunk["messages"][-1].pretty_print() |
|
|
| |
| input_messages = [HumanMessage(content="When creating or updating ToDo items, include specific local businesses / vendors.")] |
| print("------------------") |
| print("Mensaje: 3") |
| print("------------------") |
| |
| for chunk in graph.stream({"messages": input_messages}, config, stream_mode="values"): |
| chunk["messages"][-1].pretty_print() |
|
|
|
|
| |
| user_id = "Lance" |
| print("------------------") |
| print("Mensaje: 4 Actualización") |
| print("------------------") |
| |
| for memory in across_thread_memory.search(("instructions", user_id)): |
| print(memory.value) |
|
|
| |
| input_messages = [HumanMessage(content="I need to fix the jammed electric Yale lock on the door.")] |
| print("------------------") |
| print("Mensaje: 5") |
| print("------------------") |
| |
| for chunk in graph.stream({"messages": input_messages}, config, stream_mode="values"): |
| chunk["messages"][-1].pretty_print() |
|
|
| |
| user_id = "Lance" |
| print("------------------") |
| print("Mensaje: 6") |
| print("------------------") |
| |
| for memory in across_thread_memory.search(("todo", user_id)): |
| print(memory.value) |
|
|
| |
| input_messages = [HumanMessage(content="For the swim lessons, I need to get that done by end of November.")] |
| print("------------------") |
| print("Mensaje: 7") |
| print("------------------") |
| |
| for chunk in graph.stream({"messages": input_messages}, config, stream_mode="values"): |
| chunk["messages"][-1].pretty_print() |