diff --git a/docs/docs/how-tos/memory/shared-state.ipynb b/docs/docs/how-tos/memory/shared-state.ipynb index 979e0497b..83f18484c 100644 --- a/docs/docs/how-tos/memory/shared-state.ipynb +++ b/docs/docs/how-tos/memory/shared-state.ipynb @@ -7,10 +7,11 @@ "source": [ "# How to share state between threads\n", "\n", - "By default, state in a graph is scoped to that thread.\n", - "LangGraph also allows you to specify a \"scope\" for a given key/value pair that exists between threads. This can be useful for storing information that is shared between threads. For instance, you may want to store information about a user's preferences expressed in one thread, and then use that information in another thread.\n", + "By default, state is scoped to a single thread. LangGraph also lets you customize the scope for a given key-value pair. You can use this to share information between threads.\n", "\n", - "In this notebook we will go through an example of how to construct and use such a graph.\n", + "For instance, you can persist each user’s preferences to shared state and reuse them in new conversational threads.\n", + "\n", + "In this notebook, we will show how to construct and use such a graph.\n", "\n", "## Setup\n", "\n", @@ -19,7 +20,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "3457aadf", "metadata": {}, "outputs": [], @@ -30,7 +31,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "aa2c64a7", "metadata": {}, "outputs": [], @@ -73,44 +74,44 @@ "
\n", "

Typing shared state keys

\n", "

\n", - " Shared state channels (keys) MUST be dictionaries (see info channel in the AgentState example below)\n", + " Shared state channels (keys) MUST be dictionaries (see info channel in the State example below)\n", "

\n", "
" ] }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 5, "id": "a7f303d6-612e-4e34-bf36-29d4ed25d802", "metadata": {}, "outputs": [], "source": [ - "from langgraph.graph.graph import START, END\n", "from langgraph.graph.message import MessagesState\n", "from langgraph.graph.state import StateGraph\n", "from langgraph.store.memory import MemoryStore\n", "from langgraph.managed.shared_value import SharedValue\n", - "from typing import TypedDict, Annotated, Any\n", + "from typing import Literal, TypedDict, Annotated\n", "import uuid\n", "from langchain_openai import ChatOpenAI\n", "from langgraph.checkpoint.memory import MemorySaver\n", "\n", "\n", - "class AgentState(MessagesState):\n", + "class State(MessagesState):\n", " # We use an info key to track information\n", " # This is scoped to a user_id, so it will be information specific to each user\n", - " info: Annotated[dict, SharedValue.on(\"user_id\")]\n", + " info: Annotated[dict[str, dict], SharedValue.on(\"user_id\")]\n", "\n", "\n", "# We will give this as a tool to the agent\n", "# This will let the agent call this tool to save a fact\n", "class Info(TypedDict):\n", " \"\"\"This tool should be called when you want to save a new fact about the user.\n", - " \n", + "\n", " Attributes:\n", " fact (str): A fact about the user.\n", " topic (str): The topic related the fact is about, i.e. Food, Location, Movies, etc.\n", " \"\"\"\n", + "\n", " fact: str\n", " topic: str\n", "\n", @@ -118,17 +119,19 @@ "# This is the prompt we give the agent\n", "# We will pass known info into the prompt\n", "# We will tell it to use the Info tool to save more\n", - "prompt = \"\"\"You are helpful assistant.\n", - "\n", - "Here is what you know about the user:\n", + "prompt = \"\"\"You are a helpful assistant that learns about users to provide better assistance.\n", "\n", + "Current user information:\n", "\n", "{info}\n", "\n", "\n", - "Help out the user. If the user tells you any information about themselves, save the information using the `Info` tool.\n", + "Instructions:\n", + "1. Use the `Info` tool to save new information the user shares.\n", + "2. Save facts, opinions, preferences, and experiences.\n", + "3. Your goal: Improve assistance by building a user profile over time.\n", "\n", - "This means if the user provides any sort of fact about themselves, be it an opinion they have, a fact about themselves, etc. SAVE IT!\n", + "Remember: Every piece of information helps you serve the user better in future interactions.\n", "\"\"\"\n", "\n", "\n", @@ -136,39 +139,45 @@ "model = ChatOpenAI().bind_tools([Info])\n", "\n", "\n", - "# Our first node - this will call the model\n", - "def call_model(state):\n", - " # We get all facts and assemble them into a string\n", - " facts = [d['fact'] for d in state['info'].values()]\n", - " info = \"\\n\".join(facts)\n", + "def call_model(state: State):\n", + " \"\"\"Call the model.\"\"\"\n", + " # The info value here is scoped to the user_id\n", + " info = \"\\n\".join([d[\"fact\"] for d in state[\"info\"].values()])\n", " # Format system prompt\n", " system_msg = prompt.format(info=info)\n", " # Call model\n", - " response = model.invoke([{\"role\": \"system\", \"content\": system_msg}] + state['messages'])\n", + " response = model.invoke(\n", + " [{\"role\": \"system\", \"content\": system_msg}] + state[\"messages\"]\n", + " )\n", " return {\"messages\": [response]}\n", "\n", "\n", "# Routing function to decide what to do next\n", "# If no tool calls, then we end\n", "# If tool calls, then we update memory\n", - "def route(state):\n", - " if len(state['messages'][-1].tool_calls) == 0:\n", - " return END\n", + "def route(state) -> Literal[\"__end__\", \"update_memory\"]:\n", + " if len(state[\"messages\"][-1].tool_calls) == 0:\n", + " return \"__end__\"\n", " else:\n", " return \"update_memory\"\n", "\n", "\n", - "# This function is responsible for updating the memory\n", - "def update_memory(state):\n", + "def update_memory(state: State):\n", + " \"\"\"Update the memory.\"\"\"\n", " tool_calls = []\n", " memories = {}\n", " # Each tool call is a new memory to save\n", - " for tc in state['messages'][-1].tool_calls:\n", + " for tc in state[\"messages\"][-1].tool_calls:\n", " # We append ToolMessages (to pass back to the LLM)\n", " # This is needed because OpenAI requires each tool call be followed by a ToolMessage\n", - " tool_calls.append({\"role\": \"tool\", \"content\": \"Saved!\", \"tool_call_id\": tc['id']})\n", + " tool_calls.append(\n", + " {\"role\": \"tool\", \"content\": \"Saved!\", \"tool_call_id\": tc[\"id\"]}\n", + " )\n", " # We create a new memory from this tool call\n", - " memories[str(uuid.uuid4())] = {\"fact\": tc['args']['fact'], \"topic\": tc['args']['topic']}\n", + " memories[str(uuid.uuid4())] = {\n", + " \"fact\": tc[\"args\"][\"fact\"],\n", + " \"topic\": tc[\"args\"][\"topic\"],\n", + " }\n", " # Return the messages and memories to update the state with\n", " return {\"messages\": tool_calls, \"info\": memories}\n", "\n", @@ -182,12 +191,12 @@ "kv = MemoryStore()\n", "\n", "# Construct this relatively simple graph\n", - "graph = StateGraph(AgentState)\n", + "graph = StateGraph(State)\n", "graph.add_node(call_model)\n", "graph.add_node(update_memory)\n", - "graph.add_edge(\"update_memory\", END)\n", - "graph.add_edge(START, \"call_model\")\n", - "graph.add_conditional_edges(\"call_model\", route)\n", + "graph.add_edge(\"update_memory\", \"__end__\")\n", + "graph.add_edge(\"__start__\", \"call_model\")\n", + "graph.add_conditional_edges(\"call_model\", route, [\"__end__\", \"update_memory\"])\n", "graph = graph.compile(checkpointer=memory, store=kv)" ] }, @@ -203,7 +212,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 6, "id": "18bd8679-3a73-4033-bfb4-5093ac1f5d7f", "metadata": {}, "outputs": [ @@ -211,11 +220,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'call_model': {'messages': [AIMessage(content='Hello! How can I assist you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 171, 'total_tokens': 181}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-fbbb73a4-7c94-4db1-8761-44ea2fe9feaf-0', usage_metadata={'input_tokens': 171, 'output_tokens': 10, 'total_tokens': 181})]}}\n", - "{'call_model': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_zMUXZfhOCFYvZg5TwXyBzw16', 'function': {'arguments': '{\"fact\":\"I like pepperoni pizza\",\"topic\":\"Food\"}', 'name': 'Info'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 193, 'total_tokens': 214}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-7297f9fb-1d3e-480e-b125-ab269f648158-0', tool_calls=[{'name': 'Info', 'args': {'fact': 'I like pepperoni pizza', 'topic': 'Food'}, 'id': 'call_zMUXZfhOCFYvZg5TwXyBzw16', 'type': 'tool_call'}], usage_metadata={'input_tokens': 193, 'output_tokens': 21, 'total_tokens': 214})]}}\n", - "{'update_memory': {'messages': [{'role': 'tool', 'content': 'Saved!', 'tool_call_id': 'call_zMUXZfhOCFYvZg5TwXyBzw16'}]}}\n", - "{'call_model': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_GjshujJAeqoTuuBeHCD5YTPQ', 'function': {'arguments': '{\"fact\":\"I just moved to SF\",\"topic\":\"Location\"}', 'name': 'Info'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 239, 'total_tokens': 260}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-4abea1d6-7ccb-49b4-b805-0e04ebb542e3-0', tool_calls=[{'name': 'Info', 'args': {'fact': 'I just moved to SF', 'topic': 'Location'}, 'id': 'call_GjshujJAeqoTuuBeHCD5YTPQ', 'type': 'tool_call'}], usage_metadata={'input_tokens': 239, 'output_tokens': 21, 'total_tokens': 260})]}}\n", - "{'update_memory': {'messages': [{'role': 'tool', 'content': 'Saved!', 'tool_call_id': 'call_GjshujJAeqoTuuBeHCD5YTPQ'}]}}\n" + "{'call_model': {'messages': [AIMessage(content='Hello! How can I assist you today?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 10, 'prompt_tokens': 181, 'total_tokens': 191, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-865472b7-68e0-4b93-bf63-13bd1dc4f3f0-0', usage_metadata={'input_tokens': 181, 'output_tokens': 10, 'total_tokens': 191})]}}\n", + "{'call_model': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_BcSTNM6xueW6lgcaA8GdBuy4', 'function': {'arguments': '{\"fact\":\"likes pepperoni pizza\",\"topic\":\"Food\"}', 'name': 'Info'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 20, 'prompt_tokens': 203, 'total_tokens': 223, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-8adf64eb-db04-4232-99ce-9555ac7a9146-0', tool_calls=[{'name': 'Info', 'args': {'fact': 'likes pepperoni pizza', 'topic': 'Food'}, 'id': 'call_BcSTNM6xueW6lgcaA8GdBuy4', 'type': 'tool_call'}], usage_metadata={'input_tokens': 203, 'output_tokens': 20, 'total_tokens': 223})]}}\n", + "{'update_memory': {'messages': [{'role': 'tool', 'content': 'Saved!', 'tool_call_id': 'call_BcSTNM6xueW6lgcaA8GdBuy4'}]}}\n", + "{'call_model': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_eN6R6i9jLvLNpxvXVU4A3y08', 'function': {'arguments': '{\"fact\":\"just moved to San Francisco\",\"topic\":\"Location\"}', 'name': 'Info'}, 'type': 'function'}], 'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 247, 'total_tokens': 268, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-8ab84d8c-1a21-4b07-940c-74f6248ce6ea-0', tool_calls=[{'name': 'Info', 'args': {'fact': 'just moved to San Francisco', 'topic': 'Location'}, 'id': 'call_eN6R6i9jLvLNpxvXVU4A3y08', 'type': 'tool_call'}], usage_metadata={'input_tokens': 247, 'output_tokens': 21, 'total_tokens': 268})]}}\n", + "{'update_memory': {'messages': [{'role': 'tool', 'content': 'Saved!', 'tool_call_id': 'call_eN6R6i9jLvLNpxvXVU4A3y08'}]}}\n" ] } ], @@ -223,15 +232,25 @@ "config = {\"configurable\": {\"thread_id\": \"1\", \"user_id\": \"1\"}}\n", "\n", "# First let's just say hi to the AI\n", - "for update in graph.stream({\"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]}, config, stream_mode=\"updates\"):\n", + "for update in graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]}, config, stream_mode=\"updates\"\n", + "):\n", " print(update)\n", "\n", "# Let's continue the conversation (by passing the same config) and tell the AI we like pepperoni pizza\n", - "for update in graph.stream({\"messages\": [{\"role\": \"user\", \"content\": \"i like pepperoni pizza\"}]}, config, stream_mode=\"updates\"):\n", + "for update in graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"i like pepperoni pizza\"}]},\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", " print(update)\n", "\n", "# Let's continue the conversation even further (by passing the same config) and tell the AI we live in SF\n", - "for update in graph.stream({\"messages\": [{\"role\": \"user\", \"content\": \"i also just moved to SF\"}]}, config, stream_mode=\"updates\"):\n", + "for update in graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"i also just moved to SF\"}]},\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", " print(update)" ] }, @@ -247,7 +266,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 7, "id": "e240f025-ff8b-4d17-beb7-2420c0575dd9", "metadata": {}, "outputs": [ @@ -255,14 +274,25 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'call_model': {'messages': [AIMessage(content=\"Sure! Since you just moved to San Francisco, how about trying some popular local spots? Here are a few restaurant recommendations in SF:\\n\\n1. Tony's Pizza Napoletana - Known for their delicious pepperoni pizza!\\n2. The Slanted Door - A popular Vietnamese restaurant in the city.\\n3. Zuni Cafe - A classic American restaurant with a great ambiance.\\n4. Tartine Bakery - Perfect for a casual dinner with amazing baked goods.\\n5. State Bird Provisions - A unique dining experience with small plates and a lively atmosphere.\\n\\nFeel free to explore these options and enjoy your dinner! If you need more recommendations or information about a specific cuisine, let me know!\", additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 138, 'prompt_tokens': 197, 'total_tokens': 335}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-de8ad08c-0810-4bb5-b2e8-d3dc89522f8e-0', usage_metadata={'input_tokens': 197, 'output_tokens': 138, 'total_tokens': 335})]}}\n" + "{'call_model': {'messages': [AIMessage(content=\"I can help with that! Since you just moved to San Francisco, how about trying some local favorites? Here are a few restaurants you might enjoy:\\n\\n1. Tony's Pizza Napoletana - Known for their delicious pepperoni pizza.\\n2. The House - Offers a mix of Asian fusion dishes.\\n3. Tadich Grill - A historic seafood restaurant with a cozy atmosphere.\\n4. Zuni Cafe - Famous for its roast chicken and innovative cuisine.\\n5. La Taqueria - A popular spot for authentic Mexican tacos.\\n\\nFeel free to explore these options and let me know if you'd like more recommendations or information about any specific cuisine!\", additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 131, 'prompt_tokens': 206, 'total_tokens': 337, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-8530de74-bc07-4b31-b58d-4f4c064918b6-0', usage_metadata={'input_tokens': 206, 'output_tokens': 131, 'total_tokens': 337})]}}\n" ] } ], "source": [ "config = {\"configurable\": {\"thread_id\": \"2\", \"user_id\": \"1\"}}\n", "\n", - "for update in graph.stream({\"messages\": [{\"role\": \"user\", \"content\": \"where and what should i eat for dinner? Can you list some restaurants?\"}]}, config, stream_mode=\"updates\"):\n", + "for update in graph.stream(\n", + " {\n", + " \"messages\": [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"where and what should i eat for dinner? Can you list some restaurants?\",\n", + " }\n", + " ]\n", + " },\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", " print(update)" ] }, @@ -280,7 +310,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 8, "id": "f9bf2c15", "metadata": {}, "outputs": [ @@ -288,14 +318,25 @@ "name": "stdout", "output_type": "stream", "text": [ - "{'call_model': {'messages': [AIMessage(content='I can definitely help you with that! To provide you with personalized restaurant recommendations, could you please let me know your location or any specific preferences you have for dinner?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 34, 'prompt_tokens': 185, 'total_tokens': 219}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-5a483acf-1289-4d7f-b707-97760a8c3620-0', usage_metadata={'input_tokens': 185, 'output_tokens': 34, 'total_tokens': 219})]}}\n" + "{'call_model': {'messages': [AIMessage(content='I can help you with that! Could you please provide me with your location or a preferred cuisine for dinner?', additional_kwargs={'refusal': None}, response_metadata={'token_usage': {'completion_tokens': 23, 'prompt_tokens': 195, 'total_tokens': 218, 'completion_tokens_details': {'reasoning_tokens': 0}}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-3f7eaa92-d3f0-4cca-ab13-0ecd7b922b9a-0', usage_metadata={'input_tokens': 195, 'output_tokens': 23, 'total_tokens': 218})]}}\n" ] } ], "source": [ "config = {\"configurable\": {\"thread_id\": \"3\", \"user_id\": \"2\"}}\n", "\n", - "for update in graph.stream({\"messages\": [{\"role\": \"user\", \"content\": \"where and what should i eat for dinner? Can you list some restaurants?\"}]}, config, stream_mode=\"updates\"):\n", + "for update in graph.stream(\n", + " {\n", + " \"messages\": [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"where and what should i eat for dinner? Can you list some restaurants?\",\n", + " }\n", + " ]\n", + " },\n", + " config,\n", + " stream_mode=\"updates\",\n", + "):\n", " print(update)" ] }, @@ -324,7 +365,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.9" + "version": "3.11.2" } }, "nbformat": 4, diff --git a/libs/checkpoint-postgres/pyproject.toml b/libs/checkpoint-postgres/pyproject.toml index 59592acd4..0466bff40 100644 --- a/libs/checkpoint-postgres/pyproject.toml +++ b/libs/checkpoint-postgres/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-checkpoint-postgres" -version = "1.0.7" +version = "1.0.8" description = "Library with a Postgres implementation of LangGraph checkpoint saver." authors = [] license = "MIT" diff --git a/libs/checkpoint-sqlite/pyproject.toml b/libs/checkpoint-sqlite/pyproject.toml index f37bfc72d..f050abb8d 100644 --- a/libs/checkpoint-sqlite/pyproject.toml +++ b/libs/checkpoint-sqlite/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-checkpoint-sqlite" -version = "1.0.3" +version = "1.0.4" description = "Library with a SQLite implementation of LangGraph checkpoint saver." authors = [] license = "MIT" diff --git a/libs/langgraph/langgraph/prebuilt/tool_node.py b/libs/langgraph/langgraph/prebuilt/tool_node.py index 52b80f75a..be87c0f0f 100644 --- a/libs/langgraph/langgraph/prebuilt/tool_node.py +++ b/libs/langgraph/langgraph/prebuilt/tool_node.py @@ -43,9 +43,20 @@ INVALID_TOOL_NAME_ERROR_TEMPLATE = ( TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes." -def str_output(output: Any) -> str: +def msg_content_output(output: Any) -> str | List[dict]: + recognized_content_block_types = ("image", "image_url", "text", "json") if isinstance(output, str): return output + elif all( + [ + isinstance(x, dict) and x.get("type") in recognized_content_block_types + for x in output + ] + ): + return output + # Technically a list of strings is also valid message content but it's not currently + # well tested that all chat models support this. And for backwards compatibility + # we want to make sure we don't break any existing ToolNode usage. else: try: return json.dumps(output, ensure_ascii=False) @@ -138,8 +149,9 @@ class ToolNode(RunnableCallable): tool_message: ToolMessage = self.tools_by_name[call["name"]].invoke( input, config ) - # TODO: handle this properly in core - tool_message.content = str_output(tool_message.content) + tool_message.content = cast( + Union[str, list], msg_content_output(tool_message.content) + ) return tool_message except Exception as e: if not self.handle_tool_errors: @@ -155,8 +167,9 @@ class ToolNode(RunnableCallable): tool_message: ToolMessage = await self.tools_by_name[call["name"]].ainvoke( input, config ) - # TODO: handle this properly in core - tool_message.content = str_output(tool_message.content) + tool_message.content = cast( + Union[str, list], msg_content_output(tool_message.content) + ) return tool_message except Exception as e: if not self.handle_tool_errors: diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index f673688a5..815b35cd4 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.24" +version = "0.2.25" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" diff --git a/libs/langgraph/tests/test_prebuilt.py b/libs/langgraph/tests/test_prebuilt.py index 0fc1685e5..6e1cd081a 100644 --- a/libs/langgraph/tests/test_prebuilt.py +++ b/libs/langgraph/tests/test_prebuilt.py @@ -262,6 +262,12 @@ async def test_tool_node(): {"key_1": some_other_val, "key_2": "baz"}, ] + async def tool4(some_val: int, some_other_val: str) -> str: + """Tool 4 docstring.""" + return [ + {"type": "image_url", "image_url": {"url": "abdc"}}, + ] + result = ToolNode([tool1]).invoke( { "messages": [ @@ -397,6 +403,28 @@ async def test_tool_node(): ) assert tool_message.tool_call_id == "some 0" + # list of content blocks tool content + result4 = await ToolNode([tool4]).ainvoke( + { + "messages": [ + AIMessage( + "hi?", + tool_calls=[ + { + "name": "tool4", + "args": {"some_val": 2, "some_other_val": "bar"}, + "id": "some 0", + } + ], + ) + ] + } + ) + tool_message: ToolMessage = result4["messages"][-1] + assert tool_message.type == "tool" + assert tool_message.content == [{"type": "image_url", "image_url": {"url": "abdc"}}] + assert tool_message.tool_call_id == "some 0" + def my_function(some_val: int, some_other_val: str) -> str: return f"{some_val} - {some_other_val}"