diff --git a/docs/_scripts/copy_notebooks.py b/docs/_scripts/copy_notebooks.py index 6532305a8..a33347950 100644 --- a/docs/_scripts/copy_notebooks.py +++ b/docs/_scripts/copy_notebooks.py @@ -25,7 +25,9 @@ _MANUAL = { "streaming-events-from-within-tools.ipynb", "streaming-from-final-node.ipynb", "persistence.ipynb", - "managing-conversation-history.ipynb", + "memory/manage-conversation-history.ipynb", + "memory/delete-messages.ipynb", + "memory/add-summary-conversation-history.ipynb", "persistence_postgres.ipynb", "visualization.ipynb", "state-model.ipynb", diff --git a/docs/docs/how-tos/index.md b/docs/docs/how-tos/index.md index 9f8ff0722..d24046189 100644 --- a/docs/docs/how-tos/index.md +++ b/docs/docs/how-tos/index.md @@ -22,7 +22,9 @@ These how-to guides show how to achieve that controllability. LangGraph makes it easy to persist state across graph runs. The guide below shows how to add persistence to your graph. - [How to add persistence ("memory") to your graph](persistence.ipynb) -- [How to manage conversation history](managing-conversation-history.ipynb) +- [How to manage conversation history](memory/manage-conversation-history.ipynb) +- [How to delete messages](memory/delete-messages.ipynb) +- [How to add summary conversation memory](memory/add-summary-conversation-history.ipynb) - [How to create a custom checkpointer using Postgres](persistence_postgres.ipynb) ## Human in the Loop diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 01d1d7dec..0c72a90f4 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -133,7 +133,9 @@ nav: - Create map-reduce branches for parallel execution: how-tos/map-reduce.ipynb - Persistence: - Add persistence ("memory"): how-tos/persistence.ipynb - - Manage conversation history: how-tos/managing-conversation-history.ipynb + - Manage conversation history: how-tos/memory/manage-conversation-history.ipynb + - Delete messages: how-tos/memory/delete-messages.ipynb + - Add summary of the conversation history: how-tos/memory/add-summary-conversation-history.ipynb - Create custom checkpointer using Postgres: how-tos/persistence_postgres.ipynb - Human-in-the-loop: - Add breakpoints: how-tos/human_in_the_loop/breakpoints.ipynb diff --git a/examples/memory/add-summary-conversation-history.ipynb b/examples/memory/add-summary-conversation-history.ipynb new file mode 100644 index 000000000..c905b6ba7 --- /dev/null +++ b/examples/memory/add-summary-conversation-history.ipynb @@ -0,0 +1,543 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# How to add summary of the conversation history\n", + "\n", + "One of the most common use cases for persistence is to use it to keep track of conversation history. This is great - it makes it easy to continue conversations. As conversations get longer and longer, however, this conversation history can build up and take up more and more of the context window. This can often be undesirable as it leads to more expensive and longer calls to the LLM, and potentially ones that error. One way to work around that is to create a summary of the conversation to date, and use that with the past N messages. This guide will go through an example of how to do that.\n", + "\n", + "This will involve a few steps:\n", + "- Check if the conversation is too long (can be done by checking number of messages or length of messages)\n", + "- If yes, the create summary (will need a prompt for this)\n", + "- Then remove all except the last N messages\n", + "\n", + "A big part of this is deleting old messages. For an in depth guide on how to do that, see [this guide](./delete-messages.ipynb)" + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's set up the packages we're going to want to use" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "%pip install --quiet -U langgraph langchain_anthropic" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for Anthropic (the LLM we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(var: str):\n", + " if not os.environ.get(var):\n", + " os.environ[var] = getpass.getpass(f\"{var}: \")\n", + "\n", + "\n", + "_set_env(\"ANTHROPIC_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "_set_env(\"LANGCHAIN_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "84835fdb-a5f3-4c90-85f3-0e6257650aba", + "metadata": {}, + "source": [ + "## Build the chatbot\n", + "\n", + "Let's now build the chatbot." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "378899a9-3b9a-4748-95b6-eb00e0828677", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Literal\n", + "\n", + "from langchain_anthropic import ChatAnthropic\n", + "from langchain_core.messages import SystemMessage, RemoveMessage\n", + "from langgraph.checkpoint.sqlite import SqliteSaver\n", + "from langgraph.graph import MessagesState, StateGraph, START, END\n", + "\n", + "memory = SqliteSaver.from_conn_string(\":memory:\")\n", + "\n", + "# We will add a `summary` attribute (in addition to `messages` key,\n", + "# which MessagesState already has)\n", + "class State(MessagesState):\n", + " summary: str\n", + "\n", + "# We will use this model for both the conversation and the summarization\n", + "model = ChatAnthropic(model_name=\"claude-3-haiku-20240307\")\n", + "\n", + "# Define the logic to call the model\n", + "def call_model(state: State):\n", + " # If a summary exists, we add this in as a system message\n", + " summary = state.get('summary', '')\n", + " if summary:\n", + " system_message = f\"Summary of conversation earlier: {summary}\"\n", + " messages = [SystemMessage(content=system_message)] + state['messages']\n", + " else:\n", + " messages = state['messages']\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "# We now define the logic for determining whether to end or summarize the conversation\n", + "def should_continue(state: State) -> Literal[\"summarize_conversation\", END]:\n", + " \"\"\"Return the next node to execute.\"\"\"\n", + " messages = state[\"messages\"]\n", + " # If there are more than six messages, then we summarize the conversation\n", + " if len(messages) > 6:\n", + " return \"summarize_conversation\"\n", + " # Otherwise we can just end\n", + " return END\n", + "\n", + "\n", + "def summarize_conversation(state: State):\n", + " # First, we summarize the conversation\n", + " summary = state.get('summary', '')\n", + " if summary:\n", + " # If a summary already exists, we use a different system prompt\n", + " # to summarize it than if one didn't\n", + " summary_message = (\n", + " f\"This is summary of the conversation to date: {summary}\\n\\n\"\n", + " \"Extend the summary by taking into account the new messages above:\"\n", + " )\n", + " else:\n", + " summary_message = \"Create a summary of the conversation above:\"\n", + " \n", + " messages = state['messages'] + [HumanMessage(content=summary_message)]\n", + " response = model.invoke(messages)\n", + " # We now need to delete messages that we no longer want to show up\n", + " # I will delete all but the last two messages, but you can change this\n", + " delete_messages = [RemoveMessage(id=m.id) for m in state['messages'][:-2]]\n", + " return {\n", + " \"summary\": response.content,\n", + " \"messages\": delete_messages\n", + " }\n", + " \n", + "\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(State)\n", + "\n", + "# Define the conversation node and the summarize node\n", + "workflow.add_node(\"conversation\", call_model)\n", + "workflow.add_node(summarize_conversation)\n", + "\n", + "# Set the entrypoint as conversation\n", + "workflow.add_edge(START, \"conversation\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `conversation`.\n", + " # This means these are the edges taken after the `conversation` node is called.\n", + " \"conversation\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + ")\n", + "\n", + "# We now add a normal edge from `summarize_conversation` to END.\n", + "# This means that after `summarize_conversation` is called, we end.\n", + "workflow.add_edge(\"summarize_conversation\", END)\n", + "\n", + "# Finally, we compile it!\n", + "app = workflow.compile(checkpointer=memory)" + ] + }, + { + "cell_type": "markdown", + "id": "41c2872e-04b3-4c44-9e03-9e84a5230adf", + "metadata": {}, + "source": [ + "## Using the graph" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "dc697132-8fa1-4bf5-9722-56a9859331ab", + "metadata": {}, + "outputs": [], + "source": [ + "def print_update(update):\n", + " for k, v in update.items():\n", + " for m in v['messages']:\n", + " m.pretty_print()\n", + " if 'summary' in v:\n", + " print(v['summary'])" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "57b27553-21be-43e5-ac48-d1d0a3aa0dca", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "hi! I'm bob\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "It's nice to meet you, Bob! I'm an AI assistant created by Anthropic. How can I help you today?\n", + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "what's my name?\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "Your name is Bob, as you told me at the beginning of our conversation.\n", + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "i like the celtics!\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "That's great, the Celtics are a fun team to follow! Basketball is an exciting sport. Do you have a favorite Celtics player or a favorite moment from a Celtics game you've watched? I'd be happy to discuss the team and the sport with you.\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "config = {\"configurable\": {\"thread_id\": \"4\"}}\n", + "input_message = HumanMessage(content=\"hi! I'm bob\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)\n", + "\n", + "input_message = HumanMessage(content=\"what's my name?\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)\n", + "\n", + "input_message = HumanMessage(content=\"i like the celtics!\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)" + ] + }, + { + "cell_type": "markdown", + "id": "9760e219-a7fc-4d81-b4e8-1334c5afc510", + "metadata": {}, + "source": [ + "We can see that so far no summarization has happened - this is because there are only six messages in the list." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "935265a0-d511-475a-8a0d-b3c3cc5e42a0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'messages': [HumanMessage(content=\"hi! I'm bob\", id='6534853d-b8a7-44b9-837b-eb7abaf7ebf7'),\n", + " AIMessage(content=\"It's nice to meet you, Bob! I'm an AI assistant created by Anthropic. How can I help you today?\", response_metadata={'id': 'msg_015wCFew2vwMQJcpUh2VZ5ah', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 12, 'output_tokens': 30}}, id='run-0d33008b-1094-4f5e-94ce-293283fc3024-0'),\n", + " HumanMessage(content=\"what's my name?\", id='0a4f203a-b95a-42a9-b1c5-bb20f68b3251'),\n", + " AIMessage(content='Your name is Bob, as you told me at the beginning of our conversation.', response_metadata={'id': 'msg_01PLp8wg2xDsJbNR9uCtxcGz', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 50, 'output_tokens': 19}}, id='run-3815dd4d-ee0c-4fc2-9889-f6dd40325961-0'),\n", + " HumanMessage(content='i like the celtics!', id='ac128172-42d1-4390-b7cc-7bcb2d22ee48'),\n", + " AIMessage(content=\"That's great, the Celtics are a fun team to follow! Basketball is an exciting sport. Do you have a favorite Celtics player or a favorite moment from a Celtics game you've watched? I'd be happy to discuss the team and the sport with you.\", response_metadata={'id': 'msg_01CSg5avZEx6CKcZsSvSVXpr', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 78, 'output_tokens': 61}}, id='run-698faa28-0f72-495f-8ebe-e948664d2200-0')]}" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "values = app.get_state(config).values\n", + "values" + ] + }, + { + "cell_type": "markdown", + "id": "bb40eddb-9a31-4410-a4c0-9762e2d89e56", + "metadata": {}, + "source": [ + "Now let's send another message in" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "048805a4-3d97-4e76-ac45-8d80d4364c46", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "i like how much they win\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "That's understandable, the Celtics have been one of the more successful NBA franchises over the years. Their history of winning championships is very impressive. It's always fun to follow a team that regularly competes for titles. What do you think has been the key to the Celtics' sustained success? Is there a particular era or team that stands out as your favorite?\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "Here is a summary of our conversation so far:\n", + "\n", + "- You introduced yourself as Bob and said you like the Boston Celtics basketball team.\n", + "- I acknowledged that it's nice to meet you, Bob, and noted that you had shared your name earlier in the conversation.\n", + "- You expressed that you like how much the Celtics win, and I agreed that their history of sustained success and championship pedigree is impressive.\n", + "- I asked if you have a favorite Celtics player or moment that stands out to you, and invited further discussion about the team and the sport of basketball.\n", + "- The overall tone has been friendly and conversational, with me trying to engage with your interest in the Celtics by asking follow-up questions.\n" + ] + } + ], + "source": [ + "input_message = HumanMessage(content=\"i like how much they win\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)" + ] + }, + { + "cell_type": "markdown", + "id": "6b196367-6151-4982-9430-3db7373de06e", + "metadata": {}, + "source": [ + "If we check the state now, we can see that we have a summary of the conversation, as well as the last two messages" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "09ebb693-4738-4474-a095-6491def5c5f9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'messages': [HumanMessage(content='i like how much they win', id='bb916ce7-534c-4d48-9f92-e269f9dc4859'),\n", + " AIMessage(content=\"That's understandable, the Celtics have been one of the more successful NBA franchises over the years. Their history of winning championships is very impressive. It's always fun to follow a team that regularly competes for titles. What do you think has been the key to the Celtics' sustained success? Is there a particular era or team that stands out as your favorite?\", response_metadata={'id': 'msg_01B7TMagaM8xBnYXLSMwUDAG', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 148, 'output_tokens': 82}}, id='run-c5aa9a8f-7983-4a7f-9c1e-0c0055334ac1-0')],\n", + " 'summary': \"Here is a summary of our conversation so far:\\n\\n- You introduced yourself as Bob and said you like the Boston Celtics basketball team.\\n- I acknowledged that it's nice to meet you, Bob, and noted that you had shared your name earlier in the conversation.\\n- You expressed that you like how much the Celtics win, and I agreed that their history of sustained success and championship pedigree is impressive.\\n- I asked if you have a favorite Celtics player or moment that stands out to you, and invited further discussion about the team and the sport of basketball.\\n- The overall tone has been friendly and conversational, with me trying to engage with your interest in the Celtics by asking follow-up questions.\"}" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "values = app.get_state(config).values\n", + "values" + ] + }, + { + "cell_type": "markdown", + "id": "966e4177-c0fc-4fd0-a494-dd03f7f2fddb", + "metadata": {}, + "source": [ + "We can now resume having a conversation! Note that even though we only have the last two messages, we can still ask it questions about things mentioned earlier in the conversation (because we summarized those)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "7094c5ab-66f8-42ff-b1c3-90c8a9468e62", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "what's my name?\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "In our conversation so far, you introduced yourself as Bob. I acknowledged that earlier when you had shared your name.\n" + ] + } + ], + "source": [ + "input_message = HumanMessage(content=\"what's my name?\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "40e5db8e-9db9-4ac7-9d76-a99fd4034bf3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "what NFL team do you think I like?\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "I don't actually have any information about what NFL team you might like. In our conversation so far, you've only mentioned that you're a fan of the Boston Celtics basketball team. I don't have any prior knowledge about your preferences for NFL teams. Unless you provide me with that information, I don't have a basis to guess which NFL team you might be a fan of.\n" + ] + } + ], + "source": [ + "input_message = HumanMessage(content=\"what NFL team do you think I like?\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "0a1a0fda-5309-45f0-9465-9f3dff604d74", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "i like the patriots!\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "Okay, got it! Thanks for sharing that you're also a fan of the New England Patriots in the NFL. That makes sense, given your interest in other Boston sports teams like the Celtics. The Patriots have also had a very successful run over the past couple of decades, winning multiple Super Bowls. It's fun to follow winning franchises like the Celtics and Patriots. Do you have a favorite Patriots player or moment that stands out to you?\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "================================\u001b[1m Remove Message \u001b[0m================================\n", + "\n", + "\n", + "Okay, extending the summary with the new information:\n", + "\n", + "- You initially introduced yourself as Bob and said you like the Boston Celtics basketball team. \n", + "- I acknowledged that and we discussed your appreciation for the Celtics' history of winning.\n", + "- You then asked what your name was, and I reminded you that you had introduced yourself as Bob earlier in the conversation.\n", + "- You followed up by asking what NFL team I thought you might like, and I explained that I didn't have any prior information about your NFL team preferences.\n", + "- You then revealed that you are also a fan of the New England Patriots, which made sense given your Celtics fandom.\n", + "- I responded positively to this new information, noting the Patriots' own impressive success and dynasty over the past couple of decades.\n", + "- I then asked if you have a particular favorite Patriots player or moment that stands out to you, continuing the friendly, conversational tone.\n", + "\n", + "Overall, the discussion has focused on your sports team preferences, with you sharing that you are a fan of both the Celtics and the Patriots. I've tried to engage with your interests and ask follow-up questions to keep the dialogue flowing.\n" + ] + } + ], + "source": [ + "input_message = HumanMessage(content=\"i like the patriots!\")\n", + "input_message.pretty_print()\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"updates\"):\n", + " print_update(event)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "67d26013-1362-4cee-b135-ab5c3c4eb3d0", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/memory/delete-messages.ipynb b/examples/memory/delete-messages.ipynb new file mode 100644 index 000000000..52e0f375e --- /dev/null +++ b/examples/memory/delete-messages.ipynb @@ -0,0 +1,489 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# How to delete messages\n", + "\n", + "One of the common states for a graph is a list of messages. Usually you only add messages to that state. However, sometimes you may want to remove messages (either by directly modifying the state or as part of the graph). To do that, you can use the `RemoveMessage` modifier. In this guide, we will cover how to do that.\n", + "\n", + "The key idea is that each state key has a `reducer` key. This key specifies how to combine updates to the state. The default `MessagesState` has a messages key, and the reducer for that key accepts these `RemoveMessage` modifiers. That reducer then uses these `RemoveMessage` to delete messages from the key.\n", + "\n", + "So note that just because your graph state has a key that is a list of messages, it doesn't mean that that this `RemoveMessage` modifier will work. You also have to have a `reducer` defined that knows how to work with this.\n", + "\n", + "**NOTE**: Many models expect certain rules around lists of messages. For example, some expect them to start with a `user` message, others expect all messages with tool calls to be followed by a tool message. **When deleting messages, you will want to make sure you don't violate these rules.**" + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's build a simple graph that uses messages. Note that it's using the `MessagesState` which has the required `reducer`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "%pip install --quiet -U langgraph langchain_anthropic" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for Anthropic (the LLM we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(var: str):\n", + " if not os.environ.get(var):\n", + " os.environ[var] = getpass.getpass(f\"{var}: \")\n", + "\n", + "\n", + "_set_env(\"ANTHROPIC_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "_set_env(\"LANGCHAIN_API_KEY\")" + ] + }, + { + "cell_type": "markdown", + "id": "4767ef1c-a7cf-41f8-a301-558988cb7ac5", + "metadata": {}, + "source": [ + "## Build the agent\n", + "Let's now build a simple ReAct style agent." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "378899a9-3b9a-4748-95b6-eb00e0828677", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Literal\n", + "\n", + "from langchain_anthropic import ChatAnthropic\n", + "from langchain_core.tools import tool\n", + "\n", + "from langgraph.checkpoint.sqlite import SqliteSaver\n", + "from langgraph.graph import MessagesState, StateGraph, START\n", + "from langgraph.prebuilt import ToolNode\n", + "\n", + "memory = SqliteSaver.from_conn_string(\":memory:\")\n", + "\n", + "\n", + "@tool\n", + "def search(query: str):\n", + " \"\"\"Call to surf the web.\"\"\"\n", + " # This is a placeholder for the actual implementation\n", + " # Don't let the LLM know this though 😊\n", + " return [\n", + " \"It's sunny in San Francisco, but you better look out if you're a Gemini 😈.\"\n", + " ]\n", + "\n", + "\n", + "tools = [search]\n", + "tool_node = ToolNode(tools)\n", + "model = ChatAnthropic(model_name=\"claude-3-haiku-20240307\")\n", + "bound_model = model.bind_tools(tools)\n", + "\n", + "\n", + "def should_continue(state: MessagesState) -> Literal[\"action\", \"__end__\"]:\n", + " \"\"\"Return the next node to execute.\"\"\"\n", + " last_message = state[\"messages\"][-1]\n", + " # If there is no function call, then we finish\n", + " if not last_message.tool_calls:\n", + " return \"__end__\"\n", + " # Otherwise if there is, we continue\n", + " return \"action\"\n", + "\n", + "\n", + "# Define the function that calls the model\n", + "def call_model(state: MessagesState):\n", + " response = model.invoke(state[\"messages\"])\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": response}\n", + "\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(MessagesState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", tool_node)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.add_edge(START, \"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge(\"action\", \"agent\")\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile(checkpointer=memory)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "57b27553-21be-43e5-ac48-d1d0a3aa0dca", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "hi! I'm bob\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "Hello Bob! It's nice to meet you. How can I assist you today?\n", + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "what's my name?\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "Your name is Bob, as you introduced yourself at the beginning of our conversation.\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "config = {\"configurable\": {\"thread_id\": \"2\"}}\n", + "input_message = HumanMessage(content=\"hi! I'm bob\")\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"values\"):\n", + " event[\"messages\"][-1].pretty_print()\n", + "\n", + "\n", + "input_message = HumanMessage(content=\"what's my name?\")\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"values\"):\n", + " event[\"messages\"][-1].pretty_print()" + ] + }, + { + "cell_type": "markdown", + "id": "2fb0de5b-30ec-42d4-813a-7ad63fe1c367", + "metadata": {}, + "source": [ + "## Manually deleting messages\n", + "\n", + "First, we will cover how to manually delete messages. Let's take a look at the current state of the thread:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8a850529-d038-48f7-b5a2-8d4d2923f83a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[HumanMessage(content=\"hi! I'm bob\", id='3e1098f8-2657-42d3-b58a-7c2f46930b8c'),\n", + " AIMessage(content=\"Hello Bob! It's nice to meet you. How can I assist you today?\", response_metadata={'id': 'msg_01HT8MUEN4p16wbYv9Xm7kfr', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 12, 'output_tokens': 20}}, id='run-86348912-72c4-42b0-b3e0-a47c4ebd1e52-0'),\n", + " HumanMessage(content=\"what's my name?\", id='9c3ef235-ec5c-4e57-a3b2-c17502de496d'),\n", + " AIMessage(content='Your name is Bob, as you introduced yourself at the beginning of our conversation.', response_metadata={'id': 'msg_01LVhb56f6RpAAoxASZrLzmK', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 40, 'output_tokens': 19}}, id='run-e3d7447f-046a-4dfa-8813-38134dbcd1ef-0')]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "messages = app.get_state(config).values['messages']\n", + "messages" + ] + }, + { + "cell_type": "markdown", + "id": "81be8a0a-1e94-4302-bd84-d1b72e3c501c", + "metadata": {}, + "source": [ + "We can call `update_state` and pass in the id of the first message. This will delete that message." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "df1a0970-7e64-4170-beef-2855d10eef42", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'configurable': {'thread_id': '2',\n", + " 'thread_ts': '1ef3d750-5bc4-67c6-8005-9490a1b276f5'}}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from langchain_core.messages import RemoveMessage\n", + "app.update_state(config, {\"messages\": RemoveMessage(id=messages[0].id)})" + ] + }, + { + "cell_type": "markdown", + "id": "9c9127ae-0d42-42b8-957f-ea69a5da555f", + "metadata": {}, + "source": [ + "If we now look at the messages, we can verify that the first one was deleted." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "8bfe4ffa-e170-43bc-aec4-6e36ac620931", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[AIMessage(content=\"Hello Bob! It's nice to meet you. How can I assist you today?\", response_metadata={'id': 'msg_01HT8MUEN4p16wbYv9Xm7kfr', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 12, 'output_tokens': 20}}, id='run-86348912-72c4-42b0-b3e0-a47c4ebd1e52-0'),\n", + " HumanMessage(content=\"what's my name?\", id='9c3ef235-ec5c-4e57-a3b2-c17502de496d'),\n", + " AIMessage(content='Your name is Bob, as you introduced yourself at the beginning of our conversation.', response_metadata={'id': 'msg_01LVhb56f6RpAAoxASZrLzmK', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 40, 'output_tokens': 19}}, id='run-e3d7447f-046a-4dfa-8813-38134dbcd1ef-0')]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "messages = app.get_state(config).values['messages']\n", + "messages" + ] + }, + { + "cell_type": "markdown", + "id": "ef129a75-4cad-44d7-b532-eb37b0553c0c", + "metadata": {}, + "source": [ + "## Programmatically deleting messages\n", + "\n", + "We can also delete messages programmatically from inside the graph. Here we'll modify the graph to delete any old messages (longer than 3 messages ago) at the end of a graph run." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "bb22ede0-e153-4fd0-a4c0-f9af2f7663b1", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.messages import RemoveMessage\n", + "from langgraph.graph import END\n", + "\n", + "\n", + "def delete_messages(state):\n", + " messages = state['messages']\n", + " if len(messages) > 3:\n", + " return {\"messages\": [RemoveMessage(id=m.id) for m in messages[:-3]]}\n", + "\n", + "# We need to modify the logic to call delete_messages rather than end right away\n", + "def should_continue(state: MessagesState) -> Literal[\"action\", \"delete_messages\"]:\n", + " \"\"\"Return the next node to execute.\"\"\"\n", + " last_message = state[\"messages\"][-1]\n", + " # If there is no function call, then we call our delete_messages function\n", + " if not last_message.tool_calls:\n", + " return \"delete_messages\"\n", + " # Otherwise if there is, we continue\n", + " return \"action\"\n", + "\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(MessagesState)\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", tool_node)\n", + "\n", + "# This is our new node we're defining\n", + "workflow.add_node(delete_messages)\n", + "\n", + "\n", + "workflow.add_edge(START, \"agent\")\n", + "workflow.add_conditional_edges(\"agent\", should_continue,)\n", + "workflow.add_edge(\"action\", \"agent\")\n", + "\n", + "# This is the new edge we're adding: after we delete messages, we finish\n", + "workflow.add_edge(\"delete_messages\", END)\n", + "app = workflow.compile(checkpointer=memory)" + ] + }, + { + "cell_type": "markdown", + "id": "52cbdef6-7db7-45a2-8194-de4f8929bd1f", + "metadata": {}, + "source": [ + "We can now try this out. We can call the graph twice and then check the state" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "3975f34c-c243-40ea-b9d2-424d50a48dc9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "hi! I'm bob\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "It's nice to meet you, Bob! How can I assist you today?\n", + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "what's my name?\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "You said your name is Bob.\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "You said your name is Bob.\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "config = {\"configurable\": {\"thread_id\": \"3\"}}\n", + "input_message = HumanMessage(content=\"hi! I'm bob\")\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"values\"):\n", + " event[\"messages\"][-1].pretty_print()\n", + "\n", + "\n", + "input_message = HumanMessage(content=\"what's my name?\")\n", + "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"values\"):\n", + " event[\"messages\"][-1].pretty_print()" + ] + }, + { + "cell_type": "markdown", + "id": "67b2fd2a-14a1-4c47-8632-f8cbb0ba1d35", + "metadata": {}, + "source": [ + "If we now check the state, we should see that it is only three messages long. This is because we just deleted the earlier messages - otherwise it would be four!" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "a3e15abb-81d8-4072-9f10-61ae0fd61dac", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[AIMessage(content=\"It's nice to meet you, Bob! How can I assist you today?\", response_metadata={'id': 'msg_01QMoxepDiCcKQ6XFgge1QQT', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 12, 'output_tokens': 19}}, id='run-de13ba05-095d-4fd1-907a-6766ef3bf57b-0'),\n", + " HumanMessage(content=\"what's my name?\", id='8292e725-8fc4-487e-a9b6-75f8b136bec2'),\n", + " AIMessage(content='You said your name is Bob.', response_metadata={'id': 'msg_01DfWfaxavdMCqtoQRmC3mc4', 'model': 'claude-3-haiku-20240307', 'stop_reason': 'end_turn', 'stop_sequence': None, 'usage': {'input_tokens': 39, 'output_tokens': 10}}, id='run-28167c82-e126-47e4-854c-623e50c8af22-0')]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "messages = app.get_state(config).values['messages']\n", + "messages" + ] + }, + { + "cell_type": "markdown", + "id": "359cfeae-d43a-46ee-9069-a1cab9a5720a", + "metadata": {}, + "source": [ + "Remember, when deleting messages you will want to make sure that the remaining message list is still valid. This message list **may actually not be** - this is because it currently starts with an AI message, which some models do not allow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d7222cd-5767-42f0-bc69-10615127eba5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/managing-conversation-history.ipynb b/examples/memory/manage-conversation-history.ipynb similarity index 91% rename from examples/managing-conversation-history.ipynb rename to examples/memory/manage-conversation-history.ipynb index e908cf1d5..94da14225 100644 --- a/examples/managing-conversation-history.ipynb +++ b/examples/memory/manage-conversation-history.ipynb @@ -7,7 +7,12 @@ "source": [ "# How to manage conversation history\n", "\n", - "One of the most common use cases for persistence is to use it to keep track of conversation history. This is great - it makes it easy to continue conversations. As conversations get longer and longer, however, this conversation history can build up and take up more and more of the context window. This can often be undesirable as it leads to more expensive and longer calls to the LLM, and potentially ones that error. In this notebook we will discuss a few strategies for how to deal with this." + "One of the most common use cases for persistence is to use it to keep track of conversation history. This is great - it makes it easy to continue conversations. As conversations get longer and longer, however, this conversation history can build up and take up more and more of the context window. This can often be undesirable as it leads to more expensive and longer calls to the LLM, and potentially ones that error. In order to prevent this from happening, you need to probably manage the conversation history.\n", + "\n", + "Note: this guide focuses on how to do this in LangGraph, where you can fully customize how this is done. If you want a more off-the-shelf solution, you can look into functionality provided in LangChain:\n", + "\n", + "- [How to filter messages](https://python.langchain.com/v0.2/docs/how_to/filter_messages/)\n", + "- [How to trim messages](https://python.langchain.com/v0.2/docs/how_to/trim_messages/)" ] }, { @@ -82,6 +87,7 @@ "id": "4767ef1c-a7cf-41f8-a301-558988cb7ac5", "metadata": {}, "source": [ + "## Build the agent\n", "Let's now build a simple ReAct style agent." ] }, @@ -343,6 +349,25 @@ "for event in app.stream({\"messages\": [input_message]}, config, stream_mode=\"values\"):\n", " event[\"messages\"][-1].pretty_print()" ] + }, + { + "cell_type": "markdown", + "id": "454102b6-7112-4710-aa08-ba675e8be14c", + "metadata": {}, + "source": [ + "In the above example we defined the `filter_messages` function ourselves. We also provide off-the-shelf ways to trim and filter messages in LangChain. \n", + "\n", + "- [How to filter messages](https://python.langchain.com/v0.2/docs/how_to/filter_messages/)\n", + "- [How to trim messages](https://python.langchain.com/v0.2/docs/how_to/trim_messages/)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "686861bb-ec32-46f3-b7b3-fdac106f22f6", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/libs/langgraph/langgraph/graph/message.py b/libs/langgraph/langgraph/graph/message.py index 09ca7345c..402812f84 100644 --- a/libs/langgraph/langgraph/graph/message.py +++ b/libs/langgraph/langgraph/graph/message.py @@ -78,10 +78,11 @@ def add_messages(left: Messages, right: Messages) -> Messages: # merge left_idx_by_id = {m.id: i for i, m in enumerate(left)} merged = left.copy() + ids_to_remove = set() for m in right: if (existing_idx := left_idx_by_id.get(m.id)) is not None: if isinstance(m, RemoveMessage): - del merged[existing_idx] + ids_to_remove.add(m.id) else: merged[existing_idx] = m else: @@ -91,6 +92,7 @@ def add_messages(left: Messages, right: Messages) -> Messages: ) merged.append(m) + merged = [m for m in merged if m.id not in ids_to_remove] return merged diff --git a/libs/langgraph/tests/test_messages_state.py b/libs/langgraph/tests/test_messages_state.py new file mode 100644 index 000000000..dd51bc9ff --- /dev/null +++ b/libs/langgraph/tests/test_messages_state.py @@ -0,0 +1,137 @@ +from uuid import UUID + +import pytest +from langchain_core.messages import ( + AIMessage, + HumanMessage, + RemoveMessage, + SystemMessage, +) + +from langgraph.graph import add_messages + + +def test_add_single_message(): + left = [HumanMessage(content="Hello", id="1")] + right = AIMessage(content="Hi there!", id="2") + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + assert result == expected_result + + +def test_add_multiple_messages(): + left = [HumanMessage(content="Hello", id="1")] + right = [ + AIMessage(content="Hi there!", id="2"), + SystemMessage(content="System message", id="3"), + ] + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + SystemMessage(content="System message", id="3"), + ] + assert result == expected_result + + +def test_update_existing_message(): + left = [HumanMessage(content="Hello", id="1")] + right = HumanMessage(content="Hello again", id="1") + result = add_messages(left, right) + expected_result = [HumanMessage(content="Hello again", id="1")] + assert result == expected_result + + +def test_missing_ids(): + left = [HumanMessage(content="Hello")] + right = [AIMessage(content="Hi there!")] + result = add_messages(left, right) + assert len(result) == 2 + assert all(isinstance(m.id, str) and UUID(m.id, version=4) for m in result) + + +def test_remove_message(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = RemoveMessage(id="2") + result = add_messages(left, right) + expected_result = [HumanMessage(content="Hello", id="1")] + assert result == expected_result + + +def test_duplicate_remove_message(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = [RemoveMessage(id="2"), RemoveMessage(id="2")] + result = add_messages(left, right) + expected_result = [HumanMessage(content="Hello", id="1")] + assert result == expected_result + + +def test_remove_nonexistent_message(): + left = [HumanMessage(content="Hello", id="1")] + right = RemoveMessage(id="2") + with pytest.raises( + ValueError, match="Attempting to delete a message with an ID that doesn't exist" + ): + add_messages(left, right) + + +def test_mixed_operations(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = [ + HumanMessage(content="Updated hello", id="1"), + RemoveMessage(id="2"), + SystemMessage(content="New message", id="3"), + ] + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Updated hello", id="1"), + SystemMessage(content="New message", id="3"), + ] + assert result == expected_result + + +def test_empty_inputs(): + assert add_messages([], []) == [] + assert add_messages([], [HumanMessage(content="Hello", id="1")]) == [ + HumanMessage(content="Hello", id="1") + ] + assert add_messages([HumanMessage(content="Hello", id="1")], []) == [ + HumanMessage(content="Hello", id="1") + ] + + +def test_non_list_inputs(): + left = HumanMessage(content="Hello", id="1") + right = AIMessage(content="Hi there!", id="2") + result = add_messages(left, right) + expected_result = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + assert result == expected_result + + +def test_delete_all(): + left = [ + HumanMessage(content="Hello", id="1"), + AIMessage(content="Hi there!", id="2"), + ] + right = [ + RemoveMessage(id="1"), + RemoveMessage(id="2"), + ] + result = add_messages(left, right) + expected_result = [] + assert result == expected_result diff --git a/libs/langgraph/tests/test_prebuilt.py b/libs/langgraph/tests/test_prebuilt.py index 634f6a136..506d6f6d9 100644 --- a/libs/langgraph/tests/test_prebuilt.py +++ b/libs/langgraph/tests/test_prebuilt.py @@ -22,7 +22,11 @@ from langchain_core.tools import BaseTool from langchain_core.tools import tool as dec_tool from pydantic import BaseModel as BaseModelV2 -from langgraph.prebuilt import ToolNode, ValidationNode, create_react_agent +from langgraph.prebuilt import ( + ToolNode, + ValidationNode, + create_react_agent, +) class FakeToolCallingModel(BaseChatModel):