From b5429b63424d948383e9ff5cfde83a6fd879189e Mon Sep 17 00:00:00 2001 From: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com> Date: Fri, 16 Aug 2024 15:42:05 -0700 Subject: [PATCH 01/30] Use START --- .../agent-simulation-evaluation.ipynb | 5 +- .../information-gather-prompting.ipynb | 2 + .../human_in_the_loop/review-tool-calls.ipynb | 65 +++--- examples/input_output_schema.ipynb | 4 + examples/llm-compiler/LLMCompiler.ipynb | 10 +- examples/many-tools.ipynb | 6 +- examples/pass-run-time-values-to-tools.ipynb | 1 + examples/pass_private_state.ipynb | 4 +- examples/persistence_mongodb.ipynb | 14 +- examples/persistence_postgres.ipynb | 6 +- examples/persistence_redis.ipynb | 12 +- examples/reflection/reflection.ipynb | 2 +- examples/reflexion/reflexion.ipynb | 1 + examples/streaming-content.ipynb | 4 +- ...-from-within-tools-without-langchain.ipynb | 201 +----------------- .../streaming-tokens-without-langchain.ipynb | 182 +--------------- .../tutorials/rag-agent-testing-local.ipynb | 21 +- .../tutorials/tool-calling-agent-local.ipynb | 4 + examples/web-navigation/web_voyager.ipynb | 4 +- 19 files changed, 114 insertions(+), 434 deletions(-) diff --git a/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb b/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb index c41653c50..02f8782eb 100644 --- a/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb +++ b/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb @@ -235,7 +235,7 @@ " # Call the chat bot\n", " chat_bot_response = my_chat_bot(messages)\n", " # Respond with an AI Message\n", - " return {\"messages\":[AIMessage(content=chat_bot_response[\"content\"])]}" + " return {\"messages\": [AIMessage(content=chat_bot_response[\"content\"])]}" ] }, { @@ -270,7 +270,7 @@ " # Call the simulated user\n", " response = simulated_user.invoke({\"messages\": new_messages})\n", " # This response is an AI message - we need to flip this to be a human message\n", - " return {\"messages\":[HumanMessage(content=response.content)]}" + " return {\"messages\": [HumanMessage(content=response.content)]}" ] }, { @@ -331,6 +331,7 @@ "class State(TypedDict):\n", " messages: Annotated[list, add_messages]\n", "\n", + "\n", "graph_builder = StateGraph(State)\n", "graph_builder.add_node(\"user\", simulated_user_node)\n", "graph_builder.add_node(\"chat_bot\", chat_bot_node)\n", diff --git a/examples/chatbots/information-gather-prompting.ipynb b/examples/chatbots/information-gather-prompting.ipynb index bc1248cda..d374f5d57 100644 --- a/examples/chatbots/information-gather-prompting.ipynb +++ b/examples/chatbots/information-gather-prompting.ipynb @@ -182,9 +182,11 @@ "from typing import Annotated\n", "from typing_extensions import TypedDict\n", "\n", + "\n", "class State(TypedDict):\n", " messages: Annotated[list, add_messages]\n", "\n", + "\n", "memory = MemorySaver()\n", "workflow = StateGraph(State)\n", "workflow.add_node(\"info\", chain)\n", diff --git a/examples/human_in_the_loop/review-tool-calls.ipynb b/examples/human_in_the_loop/review-tool-calls.ipynb index 5a4aeaf56..84373b90b 100644 --- a/examples/human_in_the_loop/review-tool-calls.ipynb +++ b/examples/human_in_the_loop/review-tool-calls.ipynb @@ -141,15 +141,18 @@ " print(\"----\")\n", " return \"Sunny!\"\n", "\n", - "model = ChatAnthropic(model_name=\"claude-3-5-sonnet-20240620\").bind_tools([weather_search])\n", + "\n", + "model = ChatAnthropic(model_name=\"claude-3-5-sonnet-20240620\").bind_tools(\n", + " [weather_search]\n", + ")\n", + "\n", "\n", "class State(MessagesState):\n", " \"\"\"Simple state.\"\"\"\n", "\n", + "\n", "def call_llm(state):\n", - " return {\n", - " \"messages\": [model.invoke(state['messages'])]\n", - " }\n", + " return {\"messages\": [model.invoke(state[\"messages\"])]}\n", "\n", "\n", "def human_review_node(state):\n", @@ -159,28 +162,30 @@ "def run_tool(state):\n", " new_messages = []\n", " tools = {\"weather_search\": weather_search}\n", - " tool_calls = state['messages'][-1].tool_calls\n", + " tool_calls = state[\"messages\"][-1].tool_calls\n", " for tool_call in tool_calls:\n", - " tool = tools[tool_call['name']]\n", - " result = tool.invoke(tool_call['args'])\n", - " new_messages.append({\n", - " \"role\": \"tool\",\n", - " \"name\": tool_call['name'],\n", - " \"content\": result,\n", - " \"tool_call_id\": tool_call['id']\n", - " })\n", + " tool = tools[tool_call[\"name\"]]\n", + " result = tool.invoke(tool_call[\"args\"])\n", + " new_messages.append(\n", + " {\n", + " \"role\": \"tool\",\n", + " \"name\": tool_call[\"name\"],\n", + " \"content\": result,\n", + " \"tool_call_id\": tool_call[\"id\"],\n", + " }\n", + " )\n", " return {\"messages\": new_messages}\n", "\n", "\n", "def route_after_llm(state) -> Literal[END, \"human_review_node\"]:\n", - " if len(state['messages'][-1].tool_calls) == 0:\n", + " if len(state[\"messages\"][-1].tool_calls) == 0:\n", " return END\n", " else:\n", " return \"human_review_node\"\n", "\n", "\n", "def route_after_human(state) -> Literal[\"run_tool\", \"call_llm\"]:\n", - " if isinstance(state['messages'][-1], AIMessage):\n", + " if isinstance(state[\"messages\"][-1], AIMessage):\n", " return \"run_tool\"\n", " else:\n", " return \"call_llm\"\n", @@ -460,35 +465,35 @@ "print(\"Current State:\")\n", "print(state.values)\n", "print(\"\\nCurrent Tool Call ID:\")\n", - "current_content = state.values['messages'][-1].content\n", - "current_id = state.values['messages'][-1].id\n", - "tool_call_id = state.values['messages'][-1].tool_calls[0]['id']\n", + "current_content = state.values[\"messages\"][-1].content\n", + "current_id = state.values[\"messages\"][-1].id\n", + "tool_call_id = state.values[\"messages\"][-1].tool_calls[0][\"id\"]\n", "print(tool_call_id)\n", "\n", "# We now need to construct a replacement tool call.\n", "# We will change the argument to be `San Francisco, USA`\n", "# Note that we could change any number of arguments or tool names - it just has to be a valid one\n", "new_message = {\n", - " \"role\": \"assistant\", \n", + " \"role\": \"assistant\",\n", " \"content\": current_content,\n", " \"tool_calls\": [\n", " {\n", " \"id\": tool_call_id,\n", " \"name\": \"weather_search\",\n", - " \"args\": {\"city\": \"San Francisco, USA\"}\n", + " \"args\": {\"city\": \"San Francisco, USA\"},\n", " }\n", " ],\n", " # This is important - this needs to be the same as the message you replacing!\n", " # Otherwise, it will show up as a separate message\n", - " \"id\": current_id\n", + " \"id\": current_id,\n", "}\n", "graph.update_state(\n", " # This is the config which represents this thread\n", - " thread, \n", + " thread,\n", " # This is the updated value we want to push\n", - " {\"messages\": [new_message]}, \n", + " {\"messages\": [new_message]},\n", " # We push this update acting as our human_review_node\n", - " as_node=\"human_review_node\"\n", + " as_node=\"human_review_node\",\n", ")\n", "\n", "# Let's now continue executing from here\n", @@ -595,26 +600,26 @@ "print(\"Current State:\")\n", "print(state.values)\n", "print(\"\\nCurrent Tool Call ID:\")\n", - "tool_call_id = state.values['messages'][-1].tool_calls[0]['id']\n", + "tool_call_id = state.values[\"messages\"][-1].tool_calls[0][\"id\"]\n", "print(tool_call_id)\n", "\n", "# We now need to construct a replacement tool call.\n", "# We will change the argument to be `San Francisco, USA`\n", "# Note that we could change any number of arguments or tool names - it just has to be a valid one\n", "new_message = {\n", - " \"role\": \"tool\", \n", + " \"role\": \"tool\",\n", " # This is our natural language feedback\n", " \"content\": \"User requested changes: pass in the country as well\",\n", " \"name\": \"weather_search\",\n", - " \"tool_call_id\": tool_call_id\n", + " \"tool_call_id\": tool_call_id,\n", "}\n", "graph.update_state(\n", " # This is the config which represents this thread\n", - " thread, \n", + " thread,\n", " # This is the updated value we want to push\n", - " {\"messages\": [new_message]}, \n", + " {\"messages\": [new_message]},\n", " # We push this update acting as our human_review_node\n", - " as_node=\"human_review_node\"\n", + " as_node=\"human_review_node\",\n", ")\n", "\n", "# Let's now continue executing from here\n", diff --git a/examples/input_output_schema.ipynb b/examples/input_output_schema.ipynb index 16779ba6e..4837b40a8 100644 --- a/examples/input_output_schema.ipynb +++ b/examples/input_output_schema.ipynb @@ -33,15 +33,19 @@ "from langgraph.graph import StateGraph, START, END\n", "from typing import TypedDict\n", "\n", + "\n", "class InputState(TypedDict):\n", " question: str\n", "\n", + "\n", "class OutputState(TypedDict):\n", " answer: str\n", "\n", + "\n", "def answer_node(state: InputState):\n", " return {\"answer\": \"bye\"}\n", "\n", + "\n", "graph = StateGraph(input=InputState, output=OutputState)\n", "graph.add_node(answer_node)\n", "graph.add_edge(START, \"answer_node\")\n", diff --git a/examples/llm-compiler/LLMCompiler.ipynb b/examples/llm-compiler/LLMCompiler.ipynb index bfe5736d8..2f2aa81ed 100644 --- a/examples/llm-compiler/LLMCompiler.ipynb +++ b/examples/llm-compiler/LLMCompiler.ipynb @@ -526,7 +526,7 @@ " \"tasks\": tasks,\n", " }\n", " )\n", - " return {\"messages\":[scheduled_tasks]}" + " return {\"messages\": [scheduled_tasks]}" ] }, { @@ -653,7 +653,7 @@ " )\n", " ]\n", " else:\n", - " return {\"messages\":response + [AIMessage(content=decision.action.response)]}\n", + " return {\"messages\": response + [AIMessage(content=decision.action.response)]}\n", "\n", "\n", "def select_recent_messages(state) -> dict:\n", @@ -726,9 +726,11 @@ "from langgraph.graph.message import add_messages\n", "from typing import Annotated\n", "\n", + "\n", "class State(TypedDict):\n", " messages: Annotated[list, add_messages]\n", "\n", + "\n", "graph_builder = StateGraph(State)\n", "\n", "# 1. Define vertices\n", @@ -794,7 +796,9 @@ } ], "source": [ - "for step in chain.stream({\"messages\":[HumanMessage(content=\"What's the GDP of New York?\")]}):\n", + "for step in chain.stream(\n", + " {\"messages\": [HumanMessage(content=\"What's the GDP of New York?\")]}\n", + "):\n", " print(step)\n", " print(\"---\")" ] diff --git a/examples/many-tools.ipynb b/examples/many-tools.ipynb index b415f5065..107c9921f 100644 --- a/examples/many-tools.ipynb +++ b/examples/many-tools.ipynb @@ -328,9 +328,9 @@ " \"set more_information_needed False and populate a blank string for the query.\"\n", " )\n", " input_messages = [system] + state[\"messages\"]\n", - " response = llm.bind_tools(\n", - " [QueryForTools], tool_choice=True\n", - " ).invoke(input_messages)\n", + " response = llm.bind_tools([QueryForTools], tool_choice=True).invoke(\n", + " input_messages\n", + " )\n", " query = response.tool_calls[0][\"args\"][\"query\"]\n", " tool_documents = vector_store.similarity_search(query)\n", " if hack_remove_tool_condition:\n", diff --git a/examples/pass-run-time-values-to-tools.ipynb b/examples/pass-run-time-values-to-tools.ipynb index 3c74bd06d..ab6680230 100644 --- a/examples/pass-run-time-values-to-tools.ipynb +++ b/examples/pass-run-time-values-to-tools.ipynb @@ -329,6 +329,7 @@ "\n", "tools = [get_context, cite_context_sources]\n", "\n", + "\n", "# Define the function that calls the model\n", "def call_model(state, config):\n", " messages = state[\"messages\"]\n", diff --git a/examples/pass_private_state.ipynb b/examples/pass_private_state.ipynb index 2e60d805e..d34ecc7e1 100644 --- a/examples/pass_private_state.ipynb +++ b/examples/pass_private_state.ipynb @@ -72,12 +72,12 @@ "# Node to retrieve documents\n", "def retrieve_documents(state: QueryOutputState) -> DocumentOutputState:\n", " # Replace this with real logic\n", - " return {\"docs\": [state['query']] * 2}\n", + " return {\"docs\": [state[\"query\"]] * 2}\n", "\n", "\n", "# Node to generate answer\n", "def generate(state: GenerateInputState) -> OverallState:\n", - " return {\"answer\": \"\\n\\n\".join(state['docs'] + [state['question']])}\n", + " return {\"answer\": \"\\n\\n\".join(state[\"docs\"] + [state[\"question\"]])}\n", "\n", "\n", "graph = StateGraph(OverallState)\n", diff --git a/examples/persistence_mongodb.ipynb b/examples/persistence_mongodb.ipynb index 99ce73057..54dfdc638 100644 --- a/examples/persistence_mongodb.ipynb +++ b/examples/persistence_mongodb.ipynb @@ -630,7 +630,7 @@ " upsert=True,\n", " )\n", " )\n", - " await self.db[\"checkpoint_writes\"].bulk_write(operations)\n" + " await self.db[\"checkpoint_writes\"].bulk_write(operations)" ] }, { @@ -685,7 +685,9 @@ "metadata": {}, "outputs": [], "source": [ - "with MongoDBSaver.from_conn_info(host=\"localhost\", port=27017, db_name=\"checkpoints\") as checkpointer:\n", + "with MongoDBSaver.from_conn_info(\n", + " host=\"localhost\", port=27017, db_name=\"checkpoints\"\n", + ") as checkpointer:\n", " graph = create_react_agent(model, tools=tools, checkpointer=checkpointer)\n", " config = {\"configurable\": {\"thread_id\": \"1\"}}\n", " res = graph.invoke({\"messages\": [(\"human\", \"what's the weather in sf\")]}, config)\n", @@ -796,10 +798,14 @@ "metadata": {}, "outputs": [], "source": [ - "async with AsyncMongoDBSaver.from_conn_info(host=\"localhost\", port=27017, db_name=\"checkpoints\") as checkpointer:\n", + "async with AsyncMongoDBSaver.from_conn_info(\n", + " host=\"localhost\", port=27017, db_name=\"checkpoints\"\n", + ") as checkpointer:\n", " graph = create_react_agent(model, tools=tools, checkpointer=checkpointer)\n", " config = {\"configurable\": {\"thread_id\": \"2\"}}\n", - " res = await graph.ainvoke({\"messages\": [(\"human\", \"what's the weather in nyc\")]}, config)\n", + " res = await graph.ainvoke(\n", + " {\"messages\": [(\"human\", \"what's the weather in nyc\")]}, config\n", + " )\n", "\n", " latest_checkpoint = await checkpointer.aget(config)\n", " latest_checkpoint_tuple = await checkpointer.aget_tuple(config)\n", diff --git a/examples/persistence_postgres.ipynb b/examples/persistence_postgres.ipynb index ef8be17cb..6f0a814b4 100644 --- a/examples/persistence_postgres.ipynb +++ b/examples/persistence_postgres.ipynb @@ -134,7 +134,7 @@ "source": [ "from psycopg.rows import dict_row\n", "\n", - "connection_kwargs ={\n", + "connection_kwargs = {\n", " \"autocommit\": True,\n", " \"prepare_threshold\": 0,\n", " \"row_factory\": dict_row,\n", @@ -166,7 +166,7 @@ " # Example configuration\n", " conninfo=DB_URI,\n", " max_size=20,\n", - " kwargs=connection_kwargs\n", + " kwargs=connection_kwargs,\n", ")\n", "\n", "with pool.connection() as conn:\n", @@ -394,7 +394,7 @@ " # Example configuration\n", " conninfo=DB_URI,\n", " max_size=20,\n", - " kwargs=connection_kwargs\n", + " kwargs=connection_kwargs,\n", ") as pool, pool.connection() as conn:\n", " checkpointer = AsyncPostgresSaver(conn)\n", "\n", diff --git a/examples/persistence_redis.ipynb b/examples/persistence_redis.ipynb index 3b2ad1170..34d78a53f 100644 --- a/examples/persistence_redis.ipynb +++ b/examples/persistence_redis.ipynb @@ -530,7 +530,9 @@ "\n", " @classmethod\n", " @asynccontextmanager\n", - " async def from_conn_info(cls, *, host: str, port: int, db: int) -> AsyncIterator[\"AsyncRedisSaver\"]:\n", + " async def from_conn_info(\n", + " cls, *, host: str, port: int, db: int\n", + " ) -> AsyncIterator[\"AsyncRedisSaver\"]:\n", " conn = None\n", " try:\n", " conn = AsyncRedis(host=host, port=port, db=db)\n", @@ -887,10 +889,14 @@ "metadata": {}, "outputs": [], "source": [ - "async with AsyncRedisSaver.from_conn_info(host=\"localhost\", port=6379, db=0) as checkpointer:\n", + "async with AsyncRedisSaver.from_conn_info(\n", + " host=\"localhost\", port=6379, db=0\n", + ") as checkpointer:\n", " graph = create_react_agent(model, tools=tools, checkpointer=checkpointer)\n", " config = {\"configurable\": {\"thread_id\": \"2\"}}\n", - " res = await graph.ainvoke({\"messages\": [(\"human\", \"what's the weather in nyc\")]}, config)\n", + " res = await graph.ainvoke(\n", + " {\"messages\": [(\"human\", \"what's the weather in nyc\")]}, config\n", + " )\n", "\n", " latest_checkpoint = await checkpointer.aget(config)\n", " latest_checkpoint_tuple = await checkpointer.aget_tuple(config)\n", diff --git a/examples/reflection/reflection.ipynb b/examples/reflection/reflection.ipynb index 810990e38..ca42160bd 100644 --- a/examples/reflection/reflection.ipynb +++ b/examples/reflection/reflection.ipynb @@ -269,7 +269,7 @@ "class State(TypedDict):\n", " messages: Annotated[list, add_messages]\n", "\n", - " \n", + "\n", "async def generation_node(state: Sequence[BaseMessage]):\n", " return await generate.ainvoke({\"messages\": state})\n", "\n", diff --git a/examples/reflexion/reflexion.ipynb b/examples/reflexion/reflexion.ipynb index 670e6eb5b..8c183c817 100644 --- a/examples/reflexion/reflexion.ipynb +++ b/examples/reflexion/reflexion.ipynb @@ -392,6 +392,7 @@ "class State(TypedDict):\n", " messages: Annotated[list, add_messages]\n", "\n", + "\n", "MAX_ITERATIONS = 5\n", "builder = StateGraph(State)\n", "builder.add_node(\"draft\", first_responder.respond)\n", diff --git a/examples/streaming-content.ipynb b/examples/streaming-content.ipynb index ff182ca21..8c2c7ab36 100644 --- a/examples/streaming-content.ipynb +++ b/examples/streaming-content.ipynb @@ -68,7 +68,9 @@ " # It's completely optional, but useful if you have many functions with similar names\n", " gen = RunnableGenerator(my_generator).with_config(\n", " tags=[\"should_stream\"],\n", - " callbacks=config.get(\"callbacks\", []) # <-- Propagate callbacks (Python <= 3.10)\n", + " callbacks=config.get(\n", + " \"callbacks\", []\n", + " ), # <-- Propagate callbacks (Python <= 3.10)\n", " )\n", " async for message in gen.astream(state):\n", " messages.append(message)\n", diff --git a/examples/streaming-events-from-within-tools-without-langchain.ipynb b/examples/streaming-events-from-within-tools-without-langchain.ipynb index e0906c837..ab139c602 100644 --- a/examples/streaming-events-from-within-tools-without-langchain.ipynb +++ b/examples/streaming-events-from-within-tools-without-langchain.ipynb @@ -30,10 +30,7 @@ "id": "47f79af8-58d8-4a48-8d9a-88823d88701f", "metadata": {}, "outputs": [], - "source": [ - "%%capture --no-stderr\n", - "%pip install -U langgraph openai" - ] + "source": ["%%capture --no-stderr\n%pip install -U langgraph openai"] }, { "cell_type": "code", @@ -49,18 +46,7 @@ ] } ], - "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(\"OPENAI_API_KEY\")" - ] + "source": ["import getpass\nimport os\n\n\ndef _set_env(var: str):\n if not os.environ.get(var):\n os.environ[var] = getpass.getpass(f\"{var}: \")\n\n\n_set_env(\"OPENAI_API_KEY\")"] }, { "cell_type": "markdown", @@ -84,94 +70,7 @@ "id": "d59234f9-173e-469d-a725-c13e0979663e", "metadata": {}, "outputs": [], - "source": [ - "from openai import AsyncOpenAI\n", - "from langchain_core.language_models.chat_models import ChatGenerationChunk\n", - "from langchain_core.messages import AIMessageChunk\n", - "from langchain_core.runnables.config import (\n", - " ensure_config,\n", - " get_callback_manager_for_config,\n", - ")\n", - "\n", - "openai_client = AsyncOpenAI()\n", - "# define tool schema for openai tool calling\n", - "\n", - "tool = {\n", - " \"type\": \"function\",\n", - " \"function\": {\n", - " \"name\": \"get_items\",\n", - " \"description\": \"Use this tool to look up which items are in the given place.\",\n", - " \"parameters\": {\n", - " \"type\": \"object\",\n", - " \"properties\": {\"place\": {\"type\": \"string\"}},\n", - " \"required\": [\"place\"],\n", - " },\n", - " },\n", - "}\n", - "\n", - "\n", - "async def call_model(state, config=None):\n", - " config = ensure_config(config | {\"tags\": [\"agent_llm\"]})\n", - " callback_manager = get_callback_manager_for_config(config)\n", - " messages = state[\"messages\"]\n", - "\n", - " llm_run_manager = callback_manager.on_chat_model_start({}, [messages])[0]\n", - " response = await openai_client.chat.completions.create(\n", - " messages=messages, model=\"gpt-3.5-turbo\", tools=[tool], stream=True\n", - " )\n", - "\n", - " response_content = \"\"\n", - " role = None\n", - "\n", - " tool_call_id = None\n", - " tool_call_function_name = None\n", - " tool_call_function_arguments = \"\"\n", - " async for chunk in response:\n", - " delta = chunk.choices[0].delta\n", - " if delta.role is not None:\n", - " role = delta.role\n", - "\n", - " if delta.content:\n", - " response_content += delta.content\n", - " llm_run_manager.on_llm_new_token(delta.content)\n", - "\n", - " if delta.tool_calls:\n", - " # note: for simplicity we're only handling a single tool call here\n", - " if delta.tool_calls[0].function.name is not None:\n", - " tool_call_function_name = delta.tool_calls[0].function.name\n", - " tool_call_id = delta.tool_calls[0].id\n", - "\n", - " # note: we're wrapping the tools calls in ChatGenerationChunk so that the events from .astream_events in the graph can render tool calls correctly\n", - " tool_call_chunk = ChatGenerationChunk(\n", - " message=AIMessageChunk(\n", - " content=\"\",\n", - " additional_kwargs={\"tool_calls\": [delta.tool_calls[0].dict()]},\n", - " )\n", - " )\n", - " llm_run_manager.on_llm_new_token(\"\", chunk=tool_call_chunk)\n", - " tool_call_function_arguments += delta.tool_calls[0].function.arguments\n", - "\n", - " if tool_call_function_name is not None:\n", - " tool_calls = [\n", - " {\n", - " \"id\": tool_call_id,\n", - " \"function\": {\n", - " \"name\": tool_call_function_name,\n", - " \"arguments\": tool_call_function_arguments,\n", - " },\n", - " \"type\": \"function\",\n", - " }\n", - " ]\n", - " else:\n", - " tool_calls = None\n", - "\n", - " response_message = {\n", - " \"role\": role,\n", - " \"content\": response_content,\n", - " \"tool_calls\": tool_calls,\n", - " }\n", - " return {\"messages\": [response_message]}" - ] + "source": ["from openai import AsyncOpenAI\nfrom langchain_core.language_models.chat_models import ChatGenerationChunk\nfrom langchain_core.messages import AIMessageChunk\nfrom langchain_core.runnables.config import (\n ensure_config,\n get_callback_manager_for_config,\n)\n\nopenai_client = AsyncOpenAI()\n# define tool schema for openai tool calling\n\ntool = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"get_items\",\n \"description\": \"Use this tool to look up which items are in the given place.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\"place\": {\"type\": \"string\"}},\n \"required\": [\"place\"],\n },\n },\n}\n\n\nasync def call_model(state, config=None):\n config = ensure_config(config | {\"tags\": [\"agent_llm\"]})\n callback_manager = get_callback_manager_for_config(config)\n messages = state[\"messages\"]\n\n llm_run_manager = callback_manager.on_chat_model_start({}, [messages])[0]\n response = await openai_client.chat.completions.create(\n messages=messages, model=\"gpt-3.5-turbo\", tools=[tool], stream=True\n )\n\n response_content = \"\"\n role = None\n\n tool_call_id = None\n tool_call_function_name = None\n tool_call_function_arguments = \"\"\n async for chunk in response:\n delta = chunk.choices[0].delta\n if delta.role is not None:\n role = delta.role\n\n if delta.content:\n response_content += delta.content\n llm_run_manager.on_llm_new_token(delta.content)\n\n if delta.tool_calls:\n # note: for simplicity we're only handling a single tool call here\n if delta.tool_calls[0].function.name is not None:\n tool_call_function_name = delta.tool_calls[0].function.name\n tool_call_id = delta.tool_calls[0].id\n\n # note: we're wrapping the tools calls in ChatGenerationChunk so that the events from .astream_events in the graph can render tool calls correctly\n tool_call_chunk = ChatGenerationChunk(\n message=AIMessageChunk(\n content=\"\",\n additional_kwargs={\"tool_calls\": [delta.tool_calls[0].dict()]},\n )\n )\n llm_run_manager.on_llm_new_token(\"\", chunk=tool_call_chunk)\n tool_call_function_arguments += delta.tool_calls[0].function.arguments\n\n if tool_call_function_name is not None:\n tool_calls = [\n {\n \"id\": tool_call_id,\n \"function\": {\n \"name\": tool_call_function_name,\n \"arguments\": tool_call_function_arguments,\n },\n \"type\": \"function\",\n }\n ]\n else:\n tool_calls = None\n\n response_message = {\n \"role\": role,\n \"content\": response_content,\n \"tool_calls\": tool_calls,\n }\n return {\"messages\": [response_message]}"] }, { "cell_type": "markdown", @@ -187,62 +86,7 @@ "id": "b90941d8-afe4-42ec-9262-9c3b87c3b1ec", "metadata": {}, "outputs": [], - "source": [ - "import json\n", - "from langchain_core.callbacks import adispatch_custom_event\n", - "\n", - "\n", - "async def get_items(place: str) -> str:\n", - " \"\"\"Use this tool to look up which items are in the given place.\"\"\"\n", - "\n", - " # this can be replaced with any actual streaming logic that you might have\n", - " def stream(place: str):\n", - " if \"bed\" in place: # For under the bed\n", - " yield from [\"socks\", \"shoes\", \"dust bunnies\"]\n", - " elif \"shelf\" in place: # For 'shelf'\n", - " yield from [\"books\", \"penciles\", \"pictures\"]\n", - " else: # if the agent decides to ask about a different place\n", - " yield \"cat snacks\"\n", - "\n", - " tokens = []\n", - " for token in stream(place):\n", - " await adispatch_custom_event(\n", - " # this will allow you to filter events by name\n", - " \"tool_call_token_stream\",\n", - " {\n", - " \"function_name\": \"get_items\",\n", - " \"arguments\": {\"place\": place},\n", - " \"tool_output_token\": token,\n", - " },\n", - " # this will allow you to filter events by tags\n", - " config={\"tags\": [\"tool_call\"]},\n", - " )\n", - " tokens.append(token)\n", - "\n", - " return \", \".join(tokens)\n", - "\n", - "\n", - "# define mapping to look up functions when running tools\n", - "function_name_to_function = {\"get_items\": get_items}\n", - "\n", - "\n", - "async def call_tools(state):\n", - " messages = state[\"messages\"]\n", - "\n", - " tool_call = messages[-1][\"tool_calls\"][0]\n", - " function_name = tool_call[\"function\"][\"name\"]\n", - " function_arguments = tool_call[\"function\"][\"arguments\"]\n", - " arguments = json.loads(function_arguments)\n", - "\n", - " function_response = await function_name_to_function[function_name](**arguments)\n", - " tool_message = {\n", - " \"tool_call_id\": tool_call[\"id\"],\n", - " \"role\": \"tool\",\n", - " \"name\": function_name,\n", - " \"content\": function_response,\n", - " }\n", - " return {\"messages\": [tool_message]}" - ] + "source": ["import json\nfrom langchain_core.callbacks import adispatch_custom_event\n\n\nasync def get_items(place: str) -> str:\n \"\"\"Use this tool to look up which items are in the given place.\"\"\"\n\n # this can be replaced with any actual streaming logic that you might have\n def stream(place: str):\n if \"bed\" in place: # For under the bed\n yield from [\"socks\", \"shoes\", \"dust bunnies\"]\n elif \"shelf\" in place: # For 'shelf'\n yield from [\"books\", \"penciles\", \"pictures\"]\n else: # if the agent decides to ask about a different place\n yield \"cat snacks\"\n\n tokens = []\n for token in stream(place):\n await adispatch_custom_event(\n # this will allow you to filter events by name\n \"tool_call_token_stream\",\n {\n \"function_name\": \"get_items\",\n \"arguments\": {\"place\": place},\n \"tool_output_token\": token,\n },\n # this will allow you to filter events by tags\n config={\"tags\": [\"tool_call\"]},\n )\n tokens.append(token)\n\n return \", \".join(tokens)\n\n\n# define mapping to look up functions when running tools\nfunction_name_to_function = {\"get_items\": get_items}\n\n\nasync def call_tools(state):\n messages = state[\"messages\"]\n\n tool_call = messages[-1][\"tool_calls\"][0]\n function_name = tool_call[\"function\"][\"name\"]\n function_arguments = tool_call[\"function\"][\"arguments\"]\n arguments = json.loads(function_arguments)\n\n function_response = await function_name_to_function[function_name](**arguments)\n tool_message = {\n \"tool_call_id\": tool_call[\"id\"],\n \"role\": \"tool\",\n \"name\": function_name,\n \"content\": function_response,\n }\n return {\"messages\": [tool_message]}"] }, { "cell_type": "markdown", @@ -258,33 +102,7 @@ "id": "228260be-1f9a-4195-80e0-9604f8a5dba6", "metadata": {}, "outputs": [], - "source": [ - "import operator\n", - "from typing import Annotated, TypedDict, Literal\n", - "\n", - "from langgraph.graph import StateGraph, END\n", - "\n", - "\n", - "class State(TypedDict):\n", - " messages: Annotated[list, operator.add]\n", - "\n", - "\n", - "def should_continue(state) -> Literal[\"tools\", END]:\n", - " messages = state[\"messages\"]\n", - " last_message = messages[-1]\n", - " if last_message[\"tool_calls\"]:\n", - " return \"tools\"\n", - " return END\n", - "\n", - "\n", - "workflow = StateGraph(State)\n", - "workflow.set_entry_point(\"model\")\n", - "workflow.add_node(\"model\", call_model) # i.e. our \"agent\"\n", - "workflow.add_node(\"tools\", call_tools)\n", - "workflow.add_conditional_edges(\"model\", should_continue)\n", - "workflow.add_edge(\"tools\", \"model\")\n", - "graph = workflow.compile()" - ] + "source": ["import operator\nfrom typing import Annotated, TypedDict, Literal\n\nfrom langgraph.graph import StateGraph, END, START\n\n\nclass State(TypedDict):\n messages: Annotated[list, operator.add]\n\n\ndef should_continue(state) -> Literal[\"tools\", END]:\n messages = state[\"messages\"]\n last_message = messages[-1]\n if last_message[\"tool_calls\"]:\n return \"tools\"\n return END\n\n\nworkflow = StateGraph(State)\nworkflow.add_edge(START, \"model\")\nworkflow.add_node(\"model\", call_model) # i.e. our \"agent\"\nworkflow.add_node(\"tools\", call_tools)\nworkflow.add_conditional_edges(\"model\", should_continue)\nworkflow.add_edge(\"tools\", \"model\")\ngraph = workflow.compile()"] }, { "cell_type": "markdown", @@ -318,14 +136,7 @@ ] } ], - "source": [ - "async for event in graph.astream_events(\n", - " {\"messages\": [{\"role\": \"user\", \"content\": \"what's in the bedroom\"}]}, version=\"v2\"\n", - "):\n", - " tags = event.get(\"tags\", [])\n", - " if event[\"event\"] == \"on_custom_event\" and \"tool_call\" in tags:\n", - " print(\"Tool token\", event[\"data\"][\"tool_output_token\"])" - ] + "source": ["async for event in graph.astream_events(\n {\"messages\": [{\"role\": \"user\", \"content\": \"what's in the bedroom\"}]}, version=\"v2\"\n):\n tags = event.get(\"tags\", [])\n if event[\"event\"] == \"on_custom_event\" and \"tool_call\" in tags:\n print(\"Tool token\", event[\"data\"][\"tool_output_token\"])"] } ], "metadata": { diff --git a/examples/streaming-tokens-without-langchain.ipynb b/examples/streaming-tokens-without-langchain.ipynb index d31f287f8..40ff751e0 100644 --- a/examples/streaming-tokens-without-langchain.ipynb +++ b/examples/streaming-tokens-without-langchain.ipynb @@ -30,10 +30,7 @@ "id": "47f79af8-58d8-4a48-8d9a-88823d88701f", "metadata": {}, "outputs": [], - "source": [ - "%%capture --no-stderr\n", - "%pip install -U langgraph openai" - ] + "source": ["%%capture --no-stderr\n%pip install -U langgraph openai"] }, { "cell_type": "code", @@ -49,18 +46,7 @@ ] } ], - "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(\"OPENAI_API_KEY\")" - ] + "source": ["import getpass\nimport os\n\n\ndef _set_env(var: str):\n if not os.environ.get(var):\n os.environ[var] = getpass.getpass(f\"{var}: \")\n\n\n_set_env(\"OPENAI_API_KEY\")"] }, { "cell_type": "markdown", @@ -84,94 +70,7 @@ "id": "d59234f9-173e-469d-a725-c13e0979663e", "metadata": {}, "outputs": [], - "source": [ - "from openai import AsyncOpenAI\n", - "from langchain_core.language_models.chat_models import ChatGenerationChunk\n", - "from langchain_core.messages import AIMessageChunk\n", - "from langchain_core.runnables.config import (\n", - " ensure_config,\n", - " get_callback_manager_for_config,\n", - ")\n", - "\n", - "openai_client = AsyncOpenAI()\n", - "# define tool schema for openai tool calling\n", - "\n", - "tool = {\n", - " \"type\": \"function\",\n", - " \"function\": {\n", - " \"name\": \"get_items\",\n", - " \"description\": \"Use this tool to look up which items are in the given place.\",\n", - " \"parameters\": {\n", - " \"type\": \"object\",\n", - " \"properties\": {\"place\": {\"type\": \"string\"}},\n", - " \"required\": [\"place\"],\n", - " },\n", - " },\n", - "}\n", - "\n", - "\n", - "async def call_model(state, config=None):\n", - " config = ensure_config(config | {\"tags\": [\"agent_llm\"]})\n", - " callback_manager = get_callback_manager_for_config(config)\n", - " messages = state[\"messages\"]\n", - "\n", - " llm_run_manager = callback_manager.on_chat_model_start({}, [messages])[0]\n", - " response = await openai_client.chat.completions.create(\n", - " messages=messages, model=\"gpt-3.5-turbo\", tools=[tool], stream=True\n", - " )\n", - "\n", - " response_content = \"\"\n", - " role = None\n", - "\n", - " tool_call_id = None\n", - " tool_call_function_name = None\n", - " tool_call_function_arguments = \"\"\n", - " async for chunk in response:\n", - " delta = chunk.choices[0].delta\n", - " if delta.role is not None:\n", - " role = delta.role\n", - "\n", - " if delta.content:\n", - " response_content += delta.content\n", - " llm_run_manager.on_llm_new_token(delta.content)\n", - "\n", - " if delta.tool_calls:\n", - " # note: for simplicity we're only handling a single tool call here\n", - " if delta.tool_calls[0].function.name is not None:\n", - " tool_call_function_name = delta.tool_calls[0].function.name\n", - " tool_call_id = delta.tool_calls[0].id\n", - "\n", - " # note: we're wrapping the tools calls in ChatGenerationChunk so that the events from .astream_events in the graph can render tool calls correctly\n", - " tool_call_chunk = ChatGenerationChunk(\n", - " message=AIMessageChunk(\n", - " content=\"\",\n", - " additional_kwargs={\"tool_calls\": [delta.tool_calls[0].dict()]},\n", - " )\n", - " )\n", - " llm_run_manager.on_llm_new_token(\"\", chunk=tool_call_chunk)\n", - " tool_call_function_arguments += delta.tool_calls[0].function.arguments\n", - "\n", - " if tool_call_function_name is not None:\n", - " tool_calls = [\n", - " {\n", - " \"id\": tool_call_id,\n", - " \"function\": {\n", - " \"name\": tool_call_function_name,\n", - " \"arguments\": tool_call_function_arguments,\n", - " },\n", - " \"type\": \"function\",\n", - " }\n", - " ]\n", - " else:\n", - " tool_calls = None\n", - "\n", - " response_message = {\n", - " \"role\": role,\n", - " \"content\": response_content,\n", - " \"tool_calls\": tool_calls,\n", - " }\n", - " return {\"messages\": [response_message]}" - ] + "source": ["from openai import AsyncOpenAI\nfrom langchain_core.language_models.chat_models import ChatGenerationChunk\nfrom langchain_core.messages import AIMessageChunk\nfrom langchain_core.runnables.config import (\n ensure_config,\n get_callback_manager_for_config,\n)\n\nopenai_client = AsyncOpenAI()\n# define tool schema for openai tool calling\n\ntool = {\n \"type\": \"function\",\n \"function\": {\n \"name\": \"get_items\",\n \"description\": \"Use this tool to look up which items are in the given place.\",\n \"parameters\": {\n \"type\": \"object\",\n \"properties\": {\"place\": {\"type\": \"string\"}},\n \"required\": [\"place\"],\n },\n },\n}\n\n\nasync def call_model(state, config=None):\n config = ensure_config(config | {\"tags\": [\"agent_llm\"]})\n callback_manager = get_callback_manager_for_config(config)\n messages = state[\"messages\"]\n\n llm_run_manager = callback_manager.on_chat_model_start({}, [messages])[0]\n response = await openai_client.chat.completions.create(\n messages=messages, model=\"gpt-3.5-turbo\", tools=[tool], stream=True\n )\n\n response_content = \"\"\n role = None\n\n tool_call_id = None\n tool_call_function_name = None\n tool_call_function_arguments = \"\"\n async for chunk in response:\n delta = chunk.choices[0].delta\n if delta.role is not None:\n role = delta.role\n\n if delta.content:\n response_content += delta.content\n llm_run_manager.on_llm_new_token(delta.content)\n\n if delta.tool_calls:\n # note: for simplicity we're only handling a single tool call here\n if delta.tool_calls[0].function.name is not None:\n tool_call_function_name = delta.tool_calls[0].function.name\n tool_call_id = delta.tool_calls[0].id\n\n # note: we're wrapping the tools calls in ChatGenerationChunk so that the events from .astream_events in the graph can render tool calls correctly\n tool_call_chunk = ChatGenerationChunk(\n message=AIMessageChunk(\n content=\"\",\n additional_kwargs={\"tool_calls\": [delta.tool_calls[0].dict()]},\n )\n )\n llm_run_manager.on_llm_new_token(\"\", chunk=tool_call_chunk)\n tool_call_function_arguments += delta.tool_calls[0].function.arguments\n\n if tool_call_function_name is not None:\n tool_calls = [\n {\n \"id\": tool_call_id,\n \"function\": {\n \"name\": tool_call_function_name,\n \"arguments\": tool_call_function_arguments,\n },\n \"type\": \"function\",\n }\n ]\n else:\n tool_calls = None\n\n response_message = {\n \"role\": role,\n \"content\": response_content,\n \"tool_calls\": tool_calls,\n }\n return {\"messages\": [response_message]}"] }, { "cell_type": "markdown", @@ -187,41 +86,7 @@ "id": "b756ea32", "metadata": {}, "outputs": [], - "source": [ - "import json\n", - "\n", - "\n", - "async def get_items(place: str) -> str:\n", - " \"\"\"Use this tool to look up which items are in the given place.\"\"\"\n", - " if \"bed\" in place: # For under the bed\n", - " return \"socks, shoes and dust bunnies\"\n", - " if \"shelf\" in place: # For 'shelf'\n", - " return \"books, penciles and pictures\"\n", - " else: # if the agent decides to ask about a different place\n", - " return \"cat snacks\"\n", - "\n", - "\n", - "# define mapping to look up functions when running tools\n", - "function_name_to_function = {\"get_items\": get_items}\n", - "\n", - "\n", - "async def call_tools(state):\n", - " messages = state[\"messages\"]\n", - "\n", - " tool_call = messages[-1][\"tool_calls\"][0]\n", - " function_name = tool_call[\"function\"][\"name\"]\n", - " function_arguments = tool_call[\"function\"][\"arguments\"]\n", - " arguments = json.loads(function_arguments)\n", - "\n", - " function_response = await function_name_to_function[function_name](**arguments)\n", - " tool_message = {\n", - " \"tool_call_id\": tool_call[\"id\"],\n", - " \"role\": \"tool\",\n", - " \"name\": function_name,\n", - " \"content\": function_response,\n", - " }\n", - " return {\"messages\": [tool_message]}" - ] + "source": ["import json\n\n\nasync def get_items(place: str) -> str:\n \"\"\"Use this tool to look up which items are in the given place.\"\"\"\n if \"bed\" in place: # For under the bed\n return \"socks, shoes and dust bunnies\"\n if \"shelf\" in place: # For 'shelf'\n return \"books, penciles and pictures\"\n else: # if the agent decides to ask about a different place\n return \"cat snacks\"\n\n\n# define mapping to look up functions when running tools\nfunction_name_to_function = {\"get_items\": get_items}\n\n\nasync def call_tools(state):\n messages = state[\"messages\"]\n\n tool_call = messages[-1][\"tool_calls\"][0]\n function_name = tool_call[\"function\"][\"name\"]\n function_arguments = tool_call[\"function\"][\"arguments\"]\n arguments = json.loads(function_arguments)\n\n function_response = await function_name_to_function[function_name](**arguments)\n tool_message = {\n \"tool_call_id\": tool_call[\"id\"],\n \"role\": \"tool\",\n \"name\": function_name,\n \"content\": function_response,\n }\n return {\"messages\": [tool_message]}"] }, { "cell_type": "markdown", @@ -237,33 +102,7 @@ "id": "228260be-1f9a-4195-80e0-9604f8a5dba6", "metadata": {}, "outputs": [], - "source": [ - "import operator\n", - "from typing import Annotated, TypedDict, Literal\n", - "\n", - "from langgraph.graph import StateGraph, END\n", - "\n", - "\n", - "class State(TypedDict):\n", - " messages: Annotated[list, operator.add]\n", - "\n", - "\n", - "def should_continue(state) -> Literal[\"tools\", END]:\n", - " messages = state[\"messages\"]\n", - " last_message = messages[-1]\n", - " if last_message[\"tool_calls\"]:\n", - " return \"tools\"\n", - " return END\n", - "\n", - "\n", - "workflow = StateGraph(State)\n", - "workflow.set_entry_point(\"model\")\n", - "workflow.add_node(\"model\", call_model) # i.e. our \"agent\"\n", - "workflow.add_node(\"tools\", call_tools)\n", - "workflow.add_conditional_edges(\"model\", should_continue)\n", - "workflow.add_edge(\"tools\", \"model\")\n", - "graph = workflow.compile()" - ] + "source": ["import operator\nfrom typing import Annotated, TypedDict, Literal\n\nfrom langgraph.graph import StateGraph, END, START\n\n\nclass State(TypedDict):\n messages: Annotated[list, operator.add]\n\n\ndef should_continue(state) -> Literal[\"tools\", END]:\n messages = state[\"messages\"]\n last_message = messages[-1]\n if last_message[\"tool_calls\"]:\n return \"tools\"\n return END\n\n\nworkflow = StateGraph(State)\nworkflow.add_edge(START, \"model\")\nworkflow.add_node(\"model\", call_model) # i.e. our \"agent\"\nworkflow.add_node(\"tools\", call_tools)\nworkflow.add_conditional_edges(\"model\", should_continue)\nworkflow.add_edge(\"tools\", \"model\")\ngraph = workflow.compile()"] }, { "cell_type": "markdown", @@ -328,14 +167,7 @@ ] } ], - "source": [ - "async for event in graph.astream_events(\n", - " {\"messages\": [{\"role\": \"user\", \"content\": \"what's in the bedroom\"}]}, version=\"v2\"\n", - "):\n", - " tags = event.get(\"tags\", [])\n", - " if event[\"event\"] == \"on_chat_model_stream\" and \"agent_llm\" in tags:\n", - " print(\"LLM token\", event[\"data\"][\"chunk\"].dict())" - ] + "source": ["async for event in graph.astream_events(\n {\"messages\": [{\"role\": \"user\", \"content\": \"what's in the bedroom\"}]}, version=\"v2\"\n):\n tags = event.get(\"tags\", [])\n if event[\"event\"] == \"on_chat_model_stream\" and \"agent_llm\" in tags:\n print(\"LLM token\", event[\"data\"][\"chunk\"].dict())"] }, { "cell_type": "code", @@ -343,7 +175,7 @@ "id": "adb0f7bc-6e51-478e-bd32-8f72df072d6c", "metadata": {}, "outputs": [], - "source": [] + "source": [""] } ], "metadata": { diff --git a/examples/tutorials/rag-agent-testing-local.ipynb b/examples/tutorials/rag-agent-testing-local.ipynb index f9e89e56d..7fd810943 100644 --- a/examples/tutorials/rag-agent-testing-local.ipynb +++ b/examples/tutorials/rag-agent-testing-local.ipynb @@ -169,9 +169,7 @@ "from langchain_core.output_parsers import JsonOutputParser\n", "\n", "# JSON\n", - "llm = ChatOllama(model=\"llama3.1\", \n", - " format=\"json\", \n", - " temperature=0)\n", + "llm = ChatOllama(model=\"llama3.1\", format=\"json\", temperature=0)\n", "\n", "\n", "prompt = PromptTemplate(\n", @@ -210,6 +208,7 @@ "from IPython.display import Image, display\n", "from langgraph.graph import START, END, StateGraph\n", "\n", + "\n", "class GraphState(TypedDict):\n", " \"\"\"\n", " Represents the state of our graph.\n", @@ -356,7 +355,7 @@ "workflow.add_node(\"web_search\", web_search) # web search\n", "\n", "# Build graph\n", - "workflow.set_entry_point(\"retrieve\")\n", + "workflow.add_edge(START, retrieve)\n", "workflow.add_edge(\"retrieve\", \"grade_documents\")\n", "workflow.add_conditional_edges(\n", " \"grade_documents\",\n", @@ -381,21 +380,22 @@ "metadata": {}, "outputs": [], "source": [ - "import uuid \n", + "import uuid\n", + "\n", "\n", "def predict_custom_agent_answer(example: dict):\n", - " \n", " config = {\"configurable\": {\"thread_id\": str(uuid.uuid4())}}\n", - " \n", + "\n", " state_dict = custom_graph.invoke(\n", " {\"question\": example[\"input\"], \"steps\": []}, config\n", " )\n", - " \n", + "\n", " return {\"response\": state_dict[\"generation\"], \"steps\": state_dict[\"steps\"]}\n", "\n", + "\n", "example = {\"input\": \"What are the types of agent memory?\"}\n", - "#response = predict_custom_agent_answer(example)\n", - "#response" + "# response = predict_custom_agent_answer(example)\n", + "# response" ] }, { @@ -544,6 +544,7 @@ " \"generate_answer\",\n", "]\n", "\n", + "\n", "def check_trajectory_custom(root_run: Run, example: Example) -> dict:\n", " \"\"\"\n", " Check if all expected tools are called in exact order and without any additional tool calls.\n", diff --git a/examples/tutorials/tool-calling-agent-local.ipynb b/examples/tutorials/tool-calling-agent-local.ipynb index 5c60336e8..12c43038f 100644 --- a/examples/tutorials/tool-calling-agent-local.ipynb +++ b/examples/tutorials/tool-calling-agent-local.ipynb @@ -134,6 +134,7 @@ " for d in web_results\n", " ]\n", "\n", + "\n", "# Tool list\n", "tools = [retrieve_documents, web_search]" ] @@ -152,9 +153,11 @@ "from langgraph.graph.message import AnyMessage, add_messages\n", "from typing_extensions import TypedDict\n", "\n", + "\n", "class State(TypedDict):\n", " messages: Annotated[list[AnyMessage], add_messages]\n", "\n", + "\n", "class Assistant:\n", " def __init__(self, runnable: Runnable):\n", " \"\"\"\n", @@ -291,6 +294,7 @@ "source": [ "import uuid\n", "\n", + "\n", "def predict_react_agent_answer(example: dict):\n", " \"\"\"Use this for answer evaluation\"\"\"\n", "\n", diff --git a/examples/web-navigation/web_voyager.ipynb b/examples/web-navigation/web_voyager.ipynb index 728003605..8959d2025 100644 --- a/examples/web-navigation/web_voyager.ipynb +++ b/examples/web-navigation/web_voyager.ipynb @@ -457,13 +457,13 @@ "source": [ "from langchain_core.runnables import RunnableLambda\n", "\n", - "from langgraph.graph import END, StateGraph\n", + "from langgraph.graph import END, START, StateGraph\n", "\n", "graph_builder = StateGraph(AgentState)\n", "\n", "\n", "graph_builder.add_node(\"agent\", agent)\n", - "graph_builder.set_entry_point(\"agent\")\n", + "graph_builder.add_edge(START, \"agent\")\n", "\n", "graph_builder.add_node(\"update_scratchpad\", update_scratchpad)\n", "graph_builder.add_edge(\"update_scratchpad\", \"agent\")\n", From 1bd40b2ebf1e18470a3f6eb699a26de895a9b60f Mon Sep 17 00:00:00 2001 From: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com> Date: Fri, 16 Aug 2024 15:45:56 -0700 Subject: [PATCH 02/30] Do markdown --- README.md | 4 ++-- docs/docs/cloud/deployment/graph_rebuild.md | 10 +++++----- docs/docs/cloud/deployment/setup.md | 4 ++-- docs/docs/cloud/deployment/setup_pyproject.md | 4 ++-- libs/langgraph/README.md | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 87d19961f..c3da88ec4 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ from langchain_core.messages import HumanMessage from langchain_anthropic import ChatAnthropic from langchain_core.tools import tool from langgraph.checkpoint.memory import MemorySaver -from langgraph.graph import END, StateGraph, MessagesState +from langgraph.graph import END, START, StateGraph, MessagesState from langgraph.prebuilt import ToolNode @@ -107,7 +107,7 @@ workflow.add_node("tools", tool_node) # Set the entrypoint as `agent` # This means that this node is the first one called -workflow.set_entry_point("agent") +workflow.add_edge(START, "agent") # We now add a conditional edge workflow.add_conditional_edges( diff --git a/docs/docs/cloud/deployment/graph_rebuild.md b/docs/docs/cloud/deployment/graph_rebuild.md index c7853b30a..b1034cd0f 100644 --- a/docs/docs/cloud/deployment/graph_rebuild.md +++ b/docs/docs/cloud/deployment/graph_rebuild.md @@ -28,7 +28,7 @@ In the standard LangGraph API configuration, the server uses the compiled graph ```python from langchain_openai import ChatOpenAI -from langgraph.graph import END, MessageGraph +from langgraph.graph import END, START, MessageGraph model = ChatOpenAI(temperature=0) @@ -36,7 +36,7 @@ graph_workflow = MessageGraph() graph_workflow.add_node("agent", model) graph_workflow.add_edge("agent", END) -graph_workflow.set_entry_point("agent") +graph_workflow.add_edge(START, "agent") agent = graph_workflow.compile() ``` @@ -60,7 +60,7 @@ To make your graph rebuild on each new run with custom configuration, you need t ```python from typing import Annotated, TypedDict from langchain_openai import ChatOpenAI -from langgraph.graph import END, MessageGraph +from langgraph.graph import END, START, MessageGraph from langgraph.graph.state import StateGraph from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode @@ -83,7 +83,7 @@ def make_default_graph(): graph_workflow.add_node("agent", call_model) graph_workflow.add_edge("agent", END) - graph_workflow.set_entry_point("agent") + graph_workflow.add_edge(START, "agent") agent = graph_workflow.compile() return agent @@ -113,7 +113,7 @@ def make_alternative_graph(): graph_workflow.add_node("agent", call_model) graph_workflow.add_node("tools", tool_node) graph_workflow.add_edge("tools", "agent") - graph_workflow.set_entry_point("agent") + graph_workflow.add_edge(START, "agent") graph_workflow.add_conditional_edges("agent", should_continue) agent = graph_workflow.compile() diff --git a/docs/docs/cloud/deployment/setup.md b/docs/docs/cloud/deployment/setup.md index 170e19394..c37070572 100644 --- a/docs/docs/cloud/deployment/setup.md +++ b/docs/docs/cloud/deployment/setup.md @@ -99,7 +99,7 @@ Example `agent.py` file, which shows how to import from other modules you define # my_agent/agent.py from typing import TypedDict, Literal -from langgraph.graph import StateGraph, END +from langgraph.graph import StateGraph, END, START from my_agent.utils.nodes import call_model, should_continue, tool_node # import nodes from my_agent.utils.state import AgentState # import state @@ -110,7 +110,7 @@ class GraphConfig(TypedDict): workflow = StateGraph(AgentState, config_schema=GraphConfig) workflow.add_node("agent", call_model) workflow.add_node("action", tool_node) -workflow.set_entry_point("agent") +workflow.add_edge(START, "agent") workflow.add_conditional_edges( "agent", should_continue, diff --git a/docs/docs/cloud/deployment/setup_pyproject.md b/docs/docs/cloud/deployment/setup_pyproject.md index 767171f05..c53b2d2b8 100644 --- a/docs/docs/cloud/deployment/setup_pyproject.md +++ b/docs/docs/cloud/deployment/setup_pyproject.md @@ -109,7 +109,7 @@ Example `agent.py` file, which shows how to import from other modules you define # my_agent/agent.py from typing import TypedDict, Literal -from langgraph.graph import StateGraph, END +from langgraph.graph import StateGraph, END, START from my_agent.utils.nodes import call_model, should_continue, tool_node # import nodes from my_agent.utils.state import AgentState # import state @@ -120,7 +120,7 @@ class GraphConfig(TypedDict): workflow = StateGraph(AgentState, config_schema=GraphConfig) workflow.add_node("agent", call_model) workflow.add_node("action", tool_node) -workflow.set_entry_point("agent") +workflow.add_edge(START, "agent") workflow.add_conditional_edges( "agent", should_continue, diff --git a/libs/langgraph/README.md b/libs/langgraph/README.md index 87d19961f..c3da88ec4 100644 --- a/libs/langgraph/README.md +++ b/libs/langgraph/README.md @@ -59,7 +59,7 @@ from langchain_core.messages import HumanMessage from langchain_anthropic import ChatAnthropic from langchain_core.tools import tool from langgraph.checkpoint.memory import MemorySaver -from langgraph.graph import END, StateGraph, MessagesState +from langgraph.graph import END, START, StateGraph, MessagesState from langgraph.prebuilt import ToolNode @@ -107,7 +107,7 @@ workflow.add_node("tools", tool_node) # Set the entrypoint as `agent` # This means that this node is the first one called -workflow.set_entry_point("agent") +workflow.add_edge(START, "agent") # We now add a conditional edge workflow.add_conditional_edges( From bc86757e734e37df548e30c6066dedc1f0bc6e48 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 12:46:31 -0700 Subject: [PATCH 03/30] lib: Context values never stored in checkpoints - Convert Context to a ManagedValue - Add shim for old Context constructor - Add `runtime` flag for managed values, which, prior to serialization, replaces the value with a placeholder, and replaces it back with the actual value on resuming from checkpoint --- libs/langgraph/langgraph/channels/context.py | 125 +----------------- libs/langgraph/langgraph/constants.py | 2 + libs/langgraph/langgraph/graph/state.py | 33 +++-- libs/langgraph/langgraph/managed/base.py | 38 +++++- libs/langgraph/langgraph/managed/context.py | 85 ++++++++++++ .../langgraph/managed/shared_value.py | 1 - libs/langgraph/langgraph/pregel/__init__.py | 51 +++++-- libs/langgraph/langgraph/pregel/algo.py | 30 ++++- libs/langgraph/langgraph/pregel/manager.py | 44 +++--- libs/langgraph/langgraph/pregel/read.py | 10 +- libs/langgraph/tests/test_channels.py | 84 +----------- libs/langgraph/tests/test_pregel.py | 16 ++- libs/langgraph/tests/test_pregel_async.py | 13 +- 13 files changed, 264 insertions(+), 268 deletions(-) create mode 100644 libs/langgraph/langgraph/managed/context.py diff --git a/libs/langgraph/langgraph/channels/context.py b/libs/langgraph/langgraph/channels/context.py index b48260b40..3b4e26805 100644 --- a/libs/langgraph/langgraph/channels/context.py +++ b/libs/langgraph/langgraph/channels/context.py @@ -1,124 +1,5 @@ -from contextlib import asynccontextmanager, contextmanager -from inspect import signature -from typing import ( - Any, - AsyncContextManager, - AsyncGenerator, - ContextManager, - Generator, - Generic, - Optional, - Sequence, - Type, - Union, -) +from langgraph.managed.context import Context as ContextManagedValue -from langchain_core.runnables import RunnableConfig -from typing_extensions import Self +Context = ContextManagedValue.of -from langgraph.channels.base import BaseChannel, Value -from langgraph.errors import EmptyChannelError, InvalidUpdateError - - -class Context(Generic[Value], BaseChannel[Value, None, None]): - """Exposes the value of a context manager, for the duration of an invocation. - Context manager is entered before the first step, and exited after the last step. - Optionally, provide an equivalent async context manager, which will be used - instead for async invocations. - - ```python - import httpx - - client = Channels.Context(httpx.Client, httpx.AsyncClient) - ``` - """ - - value: Value - - def __init__( - self, - ctx: Union[ - None, Type[ContextManager[Value]], Type[AsyncContextManager[Value]] - ] = None, - actx: Optional[Type[AsyncContextManager[Value]]] = None, - ) -> None: - if ctx is None and actx is None: - raise ValueError("Must provide either sync or async context manager.") - self.ctx = ctx - self.actx = actx - - def __eq__(self, value: object) -> bool: - return ( - isinstance(value, Context) - and value.ctx == self.ctx - and value.actx == self.actx - ) - - @property - def ValueType(self) -> Any: - """The type of the value stored in the channel.""" - return None - - @property - def UpdateType(self) -> Type[None]: - """The type of the update received by the channel.""" - return None - - def checkpoint(self) -> None: - raise EmptyChannelError() - - @contextmanager - def from_checkpoint( - self, checkpoint: None, config: RunnableConfig - ) -> Generator[Self, None, None]: - if self.ctx is None: - raise ValueError("Cannot enter sync context manager.") - - empty = self.__class__(ctx=self.ctx, actx=self.actx) - ctx = ( - self.ctx(config) - if signature(self.ctx).parameters.get("config") - else self.ctx() - ) - with ctx as value: - empty.value = value - yield empty - - @asynccontextmanager - async def afrom_checkpoint( - self, checkpoint: None, config: RunnableConfig - ) -> AsyncGenerator[Self, None]: - empty = self.__class__(ctx=self.ctx, actx=self.actx) - if self.actx is not None: - ctx = ( - self.actx(config) - if signature(self.actx).parameters.get("config") - else self.actx() - ) - else: - ctx = ( - self.ctx(config) - if signature(self.ctx).parameters.get("config") - else self.ctx() - ) - if hasattr(ctx, "__aenter__"): - async with ctx as value: - empty.value = value - yield empty - else: - with ctx as value: - empty.value = value - yield empty - - def update(self, values: Sequence[None]) -> bool: - if values: - raise InvalidUpdateError( - f"At key '{self.key}': Context channel does not accept writes." - ) - return False - - def get(self) -> Value: - try: - return self.value - except AttributeError: - raise EmptyChannelError() +__all__ = ["Context"] diff --git a/libs/langgraph/langgraph/constants.py b/libs/langgraph/langgraph/constants.py index 21844a565..e2621f4f6 100644 --- a/libs/langgraph/langgraph/constants.py +++ b/libs/langgraph/langgraph/constants.py @@ -11,6 +11,7 @@ CONFIG_KEY_TASK_ID = "__pregel_task_id" INTERRUPT = "__interrupt__" ERROR = "__error__" TASKS = "__pregel_tasks" +RUNTIME_PLACEHOLDER = "__pregel_runtime_placeholder__" RESERVED = { INTERRUPT, ERROR, @@ -22,6 +23,7 @@ RESERVED = { CONFIG_KEY_RESUMING, CONFIG_KEY_TASK_ID, INPUT, + RUNTIME_PLACEHOLDER, } TAG_HIDDEN = "langsmith:hidden" diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index 7228c5fa2..94de80284 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -6,6 +6,7 @@ from functools import partial from inspect import isclass, isfunction, signature from typing import ( Any, + Callable, NamedTuple, Optional, Sequence, @@ -25,7 +26,6 @@ from langchain_core.runnables.utils import ( from langgraph.channels.base import BaseChannel from langgraph.channels.binop import BinaryOperatorAggregate -from langgraph.channels.context import Context from langgraph.channels.dynamic_barrier_value import DynamicBarrierValue, WaitForNames from langgraph.channels.ephemeral_value import EphemeralValue from langgraph.channels.last_value import LastValue @@ -425,16 +425,14 @@ class StateGraph(Graph): else [ key for key, val in self.schemas[self.output].items() - if not isinstance(val, Context) and not is_managed_value(val) + if not is_managed_value(val) ] ) stream_channels = ( "__root__" if len(self.channels) == 1 and "__root__" in self.channels else [ - key - for key, val in self.channels.items() - if not isinstance(val, Context) and not is_managed_value(val) + key for key, val in self.channels.items() if not is_managed_value(val) ] ) @@ -502,7 +500,6 @@ class CompiledStateGraph(CompiledGraph): k: (self.channels[k].UpdateType, None) for k in self.builder.schemas[self.builder.input] if isinstance(self.channels[k], BaseChannel) - and not isinstance(self.channels[k], Context) }, ) @@ -523,7 +520,7 @@ class CompiledStateGraph(CompiledGraph): output_keys = [ k for k, v in self.builder.schemas[self.builder.input].items() - if not isinstance(v, Context) and not is_managed_value(v) + if not is_managed_value(v) ] else: output_keys = list(self.builder.channels) + [ @@ -650,7 +647,14 @@ class CompiledStateGraph(CompiledGraph): return ChannelWrite(writes, tags=[TAG_HIDDEN]) # attach branch publisher - self.nodes[start] |= branch.run(branch_writer, _get_state_reader(self.builder)) + schema = ( + self.builder.nodes[start].input + if start in self.builder.nodes + else self.builder.schema + ) + self.nodes[start] |= branch.run( + branch_writer, _get_state_reader(self.builder, schema) + ) # attach branch subscribers ends = ( @@ -676,16 +680,17 @@ class CompiledStateGraph(CompiledGraph): ) -def _get_state_reader(graph: StateGraph) -> ChannelRead: - state_keys = list(graph.channels) +def _get_state_reader( + builder: StateGraph, schema: Type[Any] +) -> Callable[[RunnableConfig], Any]: + state_keys = list(builder.channels) + select = list(builder.schemas[schema]) return partial( ChannelRead.do_read, - channel=state_keys[0] if state_keys == ["__root__"] else state_keys, + select=select[0] if select == ["__root__"] else select, fresh=True, # coerce state dict to schema class (eg. pydantic model) - mapper=( - None if state_keys == ["__root__"] else partial(_coerce_state, graph.schema) - ), + mapper=(None if state_keys == ["__root__"] else partial(_coerce_state, schema)), ) diff --git a/libs/langgraph/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py index bebca1be2..c5516ca1e 100644 --- a/libs/langgraph/langgraph/managed/base.py +++ b/libs/langgraph/langgraph/managed/base.py @@ -16,11 +16,16 @@ from typing import ( from langchain_core.runnables import RunnableConfig from typing_extensions import Self, TypeGuard +from langgraph.constants import RUNTIME_PLACEHOLDER + V = TypeVar("V") U = TypeVar("U") class ManagedValue(ABC, Generic[V]): + runtime: bool = False + """Whether the managed value is always created at runtime, ie. never stored.""" + def __init__(self, config: RunnableConfig) -> None: self.config = config @@ -74,8 +79,6 @@ class ConfiguredManagedValue(NamedTuple): ManagedValueSpec = Union[Type[ManagedValue], ConfiguredManagedValue] -ManagedValueMapping = dict[str, ManagedValue] - def is_managed_value(value: Any) -> TypeGuard[ManagedValueSpec]: return (isclass(value) and issubclass(value, ManagedValue)) or isinstance( @@ -103,3 +106,34 @@ def is_writable_managed_value(value: Any) -> TypeGuard[Type[WritableManagedValue ChannelKeyPlaceholder = object() ChannelTypePlaceholder = object() + + +class ManagedValueMapping(dict[str, ManagedValue]): + def replace_runtime_values(self, step: int, values: Union[dict[str, Any], Any]): + print("replace_runtime_values", values) + if isinstance(values, dict): + for key, value in values.items(): + for chan, mv in self.items(): + print("chan", chan, "mv", mv, "v", mv(step), "value", value) + print(mv, mv.runtime, mv(step) is value) + if mv.runtime and mv(step) is value: + values[key] = {RUNTIME_PLACEHOLDER: chan} + elif hasattr(values, "__dir__") and callable(values.__dir__): + for key in dir(values): + value = getattr(values, key) + for chan, mv in self.items(): + if mv.runtime and mv(step) is value: + setattr(values, key, {RUNTIME_PLACEHOLDER: chan}) + + def replace_runtime_placeholders( + self, step: int, values: Union[dict[str, Any], Any] + ): + if isinstance(values, dict): + for key, value in values.items(): + if isinstance(value, dict) and RUNTIME_PLACEHOLDER in value: + values[key] = self[value[RUNTIME_PLACEHOLDER]](step) + elif hasattr(values, "__dir__") and callable(values.__dir__): + for key in dir(values): + value = getattr(values, key) + if isinstance(value, dict) and RUNTIME_PLACEHOLDER in value: + setattr(values, key, self[value[RUNTIME_PLACEHOLDER]](step)) diff --git a/libs/langgraph/langgraph/managed/context.py b/libs/langgraph/langgraph/managed/context.py new file mode 100644 index 000000000..381257a87 --- /dev/null +++ b/libs/langgraph/langgraph/managed/context.py @@ -0,0 +1,85 @@ +from contextlib import asynccontextmanager, contextmanager +from inspect import signature +from typing import ( + Any, + AsyncContextManager, + AsyncIterator, + ContextManager, + Iterator, + Optional, + Self, + Type, + Union, +) + +from langchain_core.runnables import RunnableConfig + +from langgraph.managed.base import ConfiguredManagedValue, ManagedValue, V + + +class Context(ManagedValue): + runtime = True + + value: V + + @staticmethod + def of( + ctx: Union[None, Type[ContextManager[V]], Type[AsyncContextManager[V]]] = None, + actx: Optional[Type[AsyncContextManager[V]]] = None, + ) -> ConfiguredManagedValue: + if ctx is None and actx is None: + raise ValueError("Must provide either sync or async context manager.") + return ConfiguredManagedValue(Context, {"ctx": ctx, "actx": actx}) + + @classmethod + @contextmanager + def enter(cls, config: RunnableConfig, **kwargs: Any) -> Iterator[Self]: + with super().enter(config, **kwargs) as self: + if self.ctx is None: + raise ValueError("Cannot enter sync context manager.") + ctx = ( + self.ctx(config) + if signature(self.ctx).parameters.get("config") + else self.ctx() + ) + with ctx as v: + self.value = v + yield self + + @classmethod + @asynccontextmanager + async def aenter(cls, config: RunnableConfig, **kwargs: Any) -> AsyncIterator[Self]: + async with super().aenter(config, **kwargs) as self: + if self.actx is not None: + ctx = ( + self.actx(config) + if signature(self.actx).parameters.get("config") + else self.actx() + ) + else: + ctx = ( + self.ctx(config) + if signature(self.ctx).parameters.get("config") + else self.ctx() + ) + if hasattr(ctx, "__aenter__"): + async with ctx as v: + self.value = v + yield self + else: + with ctx as v: + self.value = v + yield self + + def __init__( + self, + config: RunnableConfig, + *, + ctx: Union[None, Type[ContextManager[V]], Type[AsyncContextManager[V]]] = None, + actx: Optional[Type[AsyncContextManager[V]]] = None, + ) -> None: + self.ctx = ctx + self.actx = actx + + def __call__(self, step: int) -> V: + return self.value diff --git a/libs/langgraph/langgraph/managed/shared_value.py b/libs/langgraph/langgraph/managed/shared_value.py index 7bb6e23b7..f5e0561bd 100644 --- a/libs/langgraph/langgraph/managed/shared_value.py +++ b/libs/langgraph/langgraph/managed/shared_value.py @@ -81,7 +81,6 @@ class SharedValue(WritableManagedValue[Value, Update]): ): raise ValueError("SharedValue must be a dict") self.scope = scope - self.config = config self.value: Value = {} self.store: BaseStore = config["configurable"].get(CONFIG_KEY_STORE) if self.store is None: diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index c06f2bce3..73db1bfb8 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -51,7 +51,6 @@ from typing_extensions import Self from langgraph.channels.base import ( BaseChannel, ) -from langgraph.channels.context import Context from langgraph.checkpoint.base import ( BaseCheckpointSaver, copy_checkpoint, @@ -68,7 +67,12 @@ from langgraph.constants import ( ) from langgraph.errors import GraphInterrupt, GraphRecursionError, InvalidUpdateError from langgraph.managed.base import ManagedValueSpec -from langgraph.pregel.algo import apply_writes, local_read, prepare_next_tasks +from langgraph.pregel.algo import ( + apply_writes, + local_read, + local_write, + prepare_next_tasks, +) from langgraph.pregel.debug import ( print_step_checkpoint, print_step_tasks, @@ -330,10 +334,7 @@ class Pregel( @property def stream_channels_asis(self) -> Union[str, Sequence[str]]: return self.stream_channels or [ - k - for k in self.channels - if isinstance(self.channels[k], BaseChannel) - and not isinstance(self.channels[k], Context) + k for k in self.channels if isinstance(self.channels[k], BaseChannel) ] def get_state(self, config: RunnableConfig) -> StateSnapshot: @@ -564,7 +565,7 @@ class Pregel( # update channels with ChannelsManager(self.channels, checkpoint, config) as ( channels, - _, + managed, ): # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() @@ -588,9 +589,22 @@ class Pregel( run_name=self.name + "UpdateState", configurable={ # deque.extend is thread-safe - CONFIG_KEY_SEND: task.writes.extend, + CONFIG_KEY_SEND: partial( + local_write, + step + 1, + task.writes.extend, + self.nodes, + channels, + managed, + ), CONFIG_KEY_READ: partial( - local_read, checkpoint, channels, task, config + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, ), }, ), @@ -682,7 +696,7 @@ class Pregel( # update channels, acting as the chosen node async with AsyncChannelsManager(self.channels, checkpoint, config) as ( channels, - _, + managed, ): # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() @@ -706,9 +720,22 @@ class Pregel( run_name=self.name + "UpdateState", configurable={ # deque.extend is thread-safe - CONFIG_KEY_SEND: task.writes.extend, + CONFIG_KEY_SEND: partial( + local_write, + step + 1, + task.writes.extend, + self.nodes, + channels, + managed, + ), CONFIG_KEY_READ: partial( - local_read, checkpoint, channels, task, config + local_read, + step + 1, + checkpoint, + channels, + managed, + task, + config, ), }, ), diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 1d3f57040..a1dd5d536 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -24,7 +24,6 @@ from langchain_core.runnables.config import ( ) from langgraph.channels.base import BaseChannel -from langgraph.channels.context import Context from langgraph.checkpoint.base import ( BaseCheckpointSaver, Checkpoint, @@ -95,28 +94,37 @@ def should_interrupt( def local_read( + step: int, checkpoint: Checkpoint, channels: Mapping[str, BaseChannel], + managed: ManagedValueMapping, task: WritesProtocol, config: RunnableConfig, select: Union[list[str], str], fresh: bool = False, ) -> Union[dict[str, Any], Any]: + if isinstance(select, str): + managed_keys = [] + else: + managed_keys = [k for k in select if k in managed] + select = [k for k in select if k not in managed] if fresh: new_checkpoint = create_checkpoint(copy_checkpoint(checkpoint), channels, -1) - context_channels = {k: v for k, v in channels.items() if isinstance(v, Context)} with ChannelsManager(channels, new_checkpoint, config, skip_context=True) as ( channels, _, ): - all_channels = {**channels, **context_channels} - apply_writes(new_checkpoint, all_channels, [task], None) - return read_channels(all_channels, select) + apply_writes(new_checkpoint, channels, [task], None) + values = read_channels(channels, select) else: - return read_channels(channels, select) + values = read_channels(channels, select) + if managed_keys: + values.update({k: managed[k](step) for k in managed_keys}) + return values def local_write( + step: int, commit: Callable[[Sequence[tuple[str, Any]]], None], processes: Mapping[str, PregelNode], channels: Mapping[str, BaseChannel], @@ -131,6 +139,9 @@ def local_write( ) if value.node not in processes: raise InvalidUpdateError(f"Invalid node name {value.node} in packet") + # replace any runtime values with placeholders + managed.replace_runtime_values(step, value.arg) + print("after replace", value) elif chan not in channels and chan not in managed: logger.warning(f"Skipping write for channel '{chan}' which has no readers") commit(writes) @@ -293,6 +304,7 @@ def prepare_next_tasks( if for_execution: proc = processes[packet.node] if node := proc.get_node(): + managed.replace_runtime_placeholders(step, packet.arg) writes = deque() tasks.append( PregelExecutableTask( @@ -317,6 +329,7 @@ def prepare_next_tasks( # deque.extend is thread-safe CONFIG_KEY_SEND: partial( local_write, + step, writes.extend, processes, channels, @@ -324,8 +337,10 @@ def prepare_next_tasks( ), CONFIG_KEY_READ: partial( local_read, + step, checkpoint, channels, + managed, PregelTaskWrites(packet.node, writes, triggers), config, ), @@ -412,6 +427,7 @@ def prepare_next_tasks( # deque.extend is thread-safe CONFIG_KEY_SEND: partial( local_write, + step, writes.extend, processes, channels, @@ -419,8 +435,10 @@ def prepare_next_tasks( ), CONFIG_KEY_READ: partial( local_read, + step, checkpoint, channels, + managed, PregelTaskWrites(name, writes, triggers), config, ), diff --git a/libs/langgraph/langgraph/pregel/manager.py b/libs/langgraph/langgraph/pregel/manager.py index 849395c50..ccfc8de8a 100644 --- a/libs/langgraph/langgraph/pregel/manager.py +++ b/libs/langgraph/langgraph/pregel/manager.py @@ -5,8 +5,6 @@ from typing import AsyncIterator, Iterator, Mapping, Optional, Union from langchain_core.runnables import RunnableConfig, patch_config from langgraph.channels.base import BaseChannel -from langgraph.channels.context import Context -from langgraph.channels.last_value import LastValue from langgraph.checkpoint.base import Checkpoint from langgraph.constants import CONFIG_KEY_STORE from langgraph.managed.base import ( @@ -14,6 +12,7 @@ from langgraph.managed.base import ( ManagedValueMapping, ManagedValueSpec, ) +from langgraph.managed.context import Context from langgraph.store.base import BaseStore @@ -31,10 +30,12 @@ def ChannelsManager( channel_specs: Mapping[str, BaseChannel] = {} managed_specs: Mapping[str, ManagedValueSpec] = {} for k, v in specs.items(): - if skip_context and isinstance(v, Context): - channel_specs[k] = LastValue(None) - elif isinstance(v, BaseChannel): + if isinstance(v, BaseChannel): channel_specs[k] = v + elif ( + skip_context and isinstance(v, ConfiguredManagedValue) and v.cls is Context + ): + managed_specs[k] = Context.of(noop_context) else: managed_specs[k] = v with ExitStack() as stack: @@ -45,14 +46,16 @@ def ChannelsManager( ) for k, v in channel_specs.items() }, - { - key: stack.enter_context( - value.cls.enter(config_for_managed, **value.kwargs) - if isinstance(value, ConfiguredManagedValue) - else value.enter(config_for_managed) - ) - for key, value in managed_specs.items() - }, + ManagedValueMapping( + { + key: stack.enter_context( + value.cls.enter(config_for_managed, **value.kwargs) + if isinstance(value, ConfiguredManagedValue) + else value.enter(config_for_managed) + ) + for key, value in managed_specs.items() + } + ), ) @@ -70,10 +73,12 @@ async def AsyncChannelsManager( channel_specs: Mapping[str, BaseChannel] = {} managed_specs: Mapping[str, ManagedValueSpec] = {} for k, v in specs.items(): - if skip_context and isinstance(v, Context): - channel_specs[k] = LastValue(None) - elif isinstance(v, BaseChannel): + if isinstance(v, BaseChannel): channel_specs[k] = v + elif ( + skip_context and isinstance(v, ConfiguredManagedValue) and v.cls is Context + ): + managed_specs[k] = Context.of(noop_context) else: managed_specs[k] = v async with AsyncExitStack() as stack: @@ -102,5 +107,10 @@ async def AsyncChannelsManager( for k, v in channel_specs.items() }, # managed: build mapping from spec to result - {tasks[task]: task.result() for task in done}, + ManagedValueMapping({tasks[task]: task.result() for task in done}), ) + + +@contextmanager +def noop_context() -> Iterator[None]: + yield None diff --git a/libs/langgraph/langgraph/pregel/read.py b/libs/langgraph/langgraph/pregel/read.py index b5c971e4a..163665e2e 100644 --- a/libs/langgraph/langgraph/pregel/read.py +++ b/libs/langgraph/langgraph/pregel/read.py @@ -67,19 +67,19 @@ class ChannelRead(RunnableCallable): def _read(self, _: Any, config: RunnableConfig) -> Any: return self.do_read( - config, channel=self.channel, fresh=self.fresh, mapper=self.mapper + config, select=self.channel, fresh=self.fresh, mapper=self.mapper ) async def _aread(self, _: Any, config: RunnableConfig) -> Any: return self.do_read( - config, channel=self.channel, fresh=self.fresh, mapper=self.mapper + config, select=self.channel, fresh=self.fresh, mapper=self.mapper ) @staticmethod def do_read( config: RunnableConfig, *, - channel: Union[str, list[str]], + select: Union[str, list[str]], fresh: bool = False, mapper: Optional[Callable[[Any], Any]] = None, ) -> Any: @@ -91,9 +91,9 @@ class ChannelRead(RunnableCallable): "Make sure to call in the context of a Pregel process" ) if mapper: - return mapper(read(channel, fresh)) + return mapper(read(select, fresh)) else: - return read(channel, fresh) + return read(select, fresh) DEFAULT_BOUND: RunnablePassthrough = RunnablePassthrough() diff --git a/libs/langgraph/tests/test_channels.py b/libs/langgraph/tests/test_channels.py index b7d936878..3e0c924ae 100644 --- a/libs/langgraph/tests/test_channels.py +++ b/libs/langgraph/tests/test_channels.py @@ -1,14 +1,9 @@ import operator -from contextlib import asynccontextmanager, contextmanager -from typing import AsyncGenerator, Generator, Sequence, Union +from typing import Sequence, Union -import httpx import pytest -from langchain_core.runnables import RunnableConfig -from pytest_mock import MockerFixture from langgraph.channels.binop import BinaryOperatorAggregate -from langgraph.channels.context import Context from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic from langgraph.errors import EmptyChannelError, InvalidUpdateError @@ -257,80 +252,3 @@ async def test_binop_async() -> None: checkpoint, {} ) as channel: assert channel.get() == 10 - - -def test_ctx_manager(mocker: MockerFixture) -> None: - setup = mocker.Mock() - cleanup = mocker.Mock() - - @contextmanager - def an_int() -> Generator[int, None, None]: - setup() - try: - yield 5 - finally: - cleanup() - - with Context(an_int, None).from_checkpoint(None, {}) as channel: - assert setup.call_count == 1 - assert cleanup.call_count == 0 - - assert channel.ValueType is None - assert channel.UpdateType is None - - assert channel.get() == 5 - - with pytest.raises(InvalidUpdateError): - channel.update([5]) # type: ignore - - assert setup.call_count == 1 - assert cleanup.call_count == 1 - - -def test_ctx_manager_ctx(mocker: MockerFixture) -> None: - with Context(httpx.Client).from_checkpoint(None, {}) as channel: - assert channel.ValueType is None - assert channel.UpdateType is None - - assert isinstance(channel.get(), httpx.Client) - - with pytest.raises(InvalidUpdateError): - channel.update([5]) # type: ignore - - with pytest.raises(EmptyChannelError): - channel.checkpoint() - - -async def test_ctx_manager_async(mocker: MockerFixture) -> None: - setup = mocker.Mock() - cleanup = mocker.Mock() - - @contextmanager - def an_int_sync(config: RunnableConfig) -> Generator[int, None, None]: - try: - yield 5 - finally: - pass - - @asynccontextmanager - async def an_int() -> AsyncGenerator[int, None]: - setup() - try: - yield 5 - finally: - cleanup() - - async with Context(an_int_sync, an_int).afrom_checkpoint(None, {}) as channel: - assert setup.call_count == 1 - assert cleanup.call_count == 0 - - assert channel.ValueType is None - assert channel.UpdateType is None - - assert channel.get() == 5 - - with pytest.raises(InvalidUpdateError): - channel.update([5]) # type: ignore - - assert setup.call_count == 1 - assert cleanup.call_count == 1 diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 7325aa41d..ad13e52c0 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -4042,7 +4042,7 @@ def test_prebuilt_tool_chat(snapshot: SnapshotAssertion) -> None: ["memory", "sqlite", "postgres", "postgres_pipe"], ) def test_state_graph_packets( - request: pytest.FixtureRequest, checkpointer_name: str + request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture ) -> None: from langchain_core.language_models.fake_chat_models import ( FakeMessagesListChatModel, @@ -4062,6 +4062,7 @@ def test_state_graph_packets( class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] + session: Annotated[httpx.Client, Context(httpx.Client)] @tool() def search_api(query: str) -> str: @@ -4117,11 +4118,19 @@ def test_state_graph_packets( ), "nodes can pass extra data to their cond edges, which isn't saved in state" # Logic to decide whether to continue in the loop or exit if tool_calls := data["messages"][-1].tool_calls: - return [Send("tools", tool_call) for tool_call in tool_calls] + return [ + Send("tools", {"call": tool_call, "my_session": data["session"]}) + for tool_call in tool_calls + ] else: return END - def tools_node(tool_call: ToolCall, config: RunnableConfig) -> AgentState: + class ToolInput(TypedDict): + call: ToolCall + my_session: httpx.Client + + def tools_node(input: ToolInput, config: RunnableConfig) -> AgentState: + tool_call = input["call"] time.sleep(tool_call["args"].get("idx", 0) / 10) output = tools_by_name[tool_call["name"]].invoke(tool_call["args"], config) return { @@ -7492,7 +7501,6 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic1( return {"answer": ",".join(data.docs)} def decider(data: State) -> str: - print("decider", data) assert isinstance(data, State) return "retriever_two" diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index a35bf7b00..6d0436bb5 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -3892,6 +3892,7 @@ async def test_state_graph_packets() -> None: class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] + session: Annotated[httpx.Client, Context(httpx.Client)] @tool() def search_api(query: str) -> str: @@ -3938,11 +3939,19 @@ async def test_state_graph_packets() -> None: def should_continue(data: AgentState) -> str: # Logic to decide whether to continue in the loop or exit if tool_calls := data["messages"][-1].tool_calls: - return [Send("tools", tool_call) for tool_call in tool_calls] + return [ + Send("tools", {"call": tool_call, "my_session": data["session"]}) + for tool_call in tool_calls + ] else: return END - async def tools_node(tool_call: ToolCall, config: RunnableConfig) -> AgentState: + class ToolInput(TypedDict): + call: ToolCall + my_session: httpx.Client + + async def tools_node(input: ToolInput, config: RunnableConfig) -> AgentState: + tool_call = input["call"] await asyncio.sleep(tool_call["args"].get("idx", 0) / 10) output = await tools_by_name[tool_call["name"]].ainvoke( tool_call["args"], config From e0898409b9aa4c3f4d8cf10cae57eeb9c716f10b Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 12:57:31 -0700 Subject: [PATCH 04/30] Lint --- libs/langgraph/langgraph/managed/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/managed/context.py b/libs/langgraph/langgraph/managed/context.py index 381257a87..ecea1ce6b 100644 --- a/libs/langgraph/langgraph/managed/context.py +++ b/libs/langgraph/langgraph/managed/context.py @@ -7,12 +7,12 @@ from typing import ( ContextManager, Iterator, Optional, - Self, Type, Union, ) from langchain_core.runnables import RunnableConfig +from typing_extensions import Self from langgraph.managed.base import ConfiguredManagedValue, ManagedValue, V From 1e6da192577060912cc37f2016412bc8cc5135fc Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 13:08:56 -0700 Subject: [PATCH 05/30] Update libs/langgraph/langgraph/managed/context.py Co-authored-by: William FH <13333726+hinthornw@users.noreply.github.com> --- libs/langgraph/langgraph/managed/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/managed/context.py b/libs/langgraph/langgraph/managed/context.py index ecea1ce6b..16dadd93b 100644 --- a/libs/langgraph/langgraph/managed/context.py +++ b/libs/langgraph/langgraph/managed/context.py @@ -36,7 +36,7 @@ class Context(ManagedValue): def enter(cls, config: RunnableConfig, **kwargs: Any) -> Iterator[Self]: with super().enter(config, **kwargs) as self: if self.ctx is None: - raise ValueError("Cannot enter sync context manager.") + raise ValueError("Synchronous context manager not found. Please initialize Context value with a sync context manager, or invoke your graph asynchronously.") ctx = ( self.ctx(config) if signature(self.ctx).parameters.get("config") From beafddf7c86eddc3db078fe3016916f792b75d38 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 13:09:16 -0700 Subject: [PATCH 06/30] Lint --- libs/langgraph/langgraph/managed/base.py | 3 --- libs/langgraph/tests/test_pregel.py | 3 +++ libs/langgraph/tests/test_pregel_async.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/libs/langgraph/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py index c5516ca1e..ca206464e 100644 --- a/libs/langgraph/langgraph/managed/base.py +++ b/libs/langgraph/langgraph/managed/base.py @@ -110,12 +110,9 @@ ChannelTypePlaceholder = object() class ManagedValueMapping(dict[str, ManagedValue]): def replace_runtime_values(self, step: int, values: Union[dict[str, Any], Any]): - print("replace_runtime_values", values) if isinstance(values, dict): for key, value in values.items(): for chan, mv in self.items(): - print("chan", chan, "mv", mv, "v", mv(step), "value", value) - print(mv, mv.runtime, mv(step) is value) if mv.runtime and mv(step) is value: values[key] = {RUNTIME_PLACEHOLDER: chan} elif hasattr(values, "__dir__") and callable(values.__dir__): diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index ad13e52c0..9f6a0c0e5 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -4106,6 +4106,7 @@ def test_state_graph_packets( ) def agent(data: AgentState) -> AgentState: + assert isinstance(data["session"], httpx.Client) return { "messages": model.invoke(data["messages"]), "something_extra": "hi there", @@ -4113,6 +4114,7 @@ def test_state_graph_packets( # Define decision-making logic def should_continue(data: AgentState) -> str: + assert isinstance(data["session"], httpx.Client) assert ( data["something_extra"] == "hi there" ), "nodes can pass extra data to their cond edges, which isn't saved in state" @@ -4130,6 +4132,7 @@ def test_state_graph_packets( my_session: httpx.Client def tools_node(input: ToolInput, config: RunnableConfig) -> AgentState: + assert isinstance(input["my_session"], httpx.Client) tool_call = input["call"] time.sleep(tool_call["args"].get("idx", 0) / 10) output = tools_by_name[tool_call["name"]].invoke(tool_call["args"], config) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 6d0436bb5..4995addb6 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -3892,7 +3892,7 @@ async def test_state_graph_packets() -> None: class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] - session: Annotated[httpx.Client, Context(httpx.Client)] + session: Annotated[httpx.AsyncClient, Context(httpx.AsyncClient)] @tool() def search_api(query: str) -> str: @@ -3937,6 +3937,7 @@ async def test_state_graph_packets() -> None: # Define decision-making logic def should_continue(data: AgentState) -> str: + assert isinstance(data["session"], httpx.AsyncClient) # Logic to decide whether to continue in the loop or exit if tool_calls := data["messages"][-1].tool_calls: return [ @@ -3951,6 +3952,7 @@ async def test_state_graph_packets() -> None: my_session: httpx.Client async def tools_node(input: ToolInput, config: RunnableConfig) -> AgentState: + assert isinstance(input["my_session"], httpx.AsyncClient) tool_call = input["call"] await asyncio.sleep(tool_call["args"].get("idx", 0) / 10) output = await tools_by_name[tool_call["name"]].ainvoke( From e8c553c41e0d573458361f503c7791f6d564db3a Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 13:12:46 -0700 Subject: [PATCH 07/30] Lint --- libs/langgraph/langgraph/managed/context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/managed/context.py b/libs/langgraph/langgraph/managed/context.py index 16dadd93b..43cff5e67 100644 --- a/libs/langgraph/langgraph/managed/context.py +++ b/libs/langgraph/langgraph/managed/context.py @@ -36,7 +36,9 @@ class Context(ManagedValue): def enter(cls, config: RunnableConfig, **kwargs: Any) -> Iterator[Self]: with super().enter(config, **kwargs) as self: if self.ctx is None: - raise ValueError("Synchronous context manager not found. Please initialize Context value with a sync context manager, or invoke your graph asynchronously.") + raise ValueError( + "Synchronous context manager not found. Please initialize Context value with a sync context manager, or invoke your graph asynchronously." + ) ctx = ( self.ctx(config) if signature(self.ctx).parameters.get("config") From 155e0c66d5644242ceb8a29370c5f43489cfdd27 Mon Sep 17 00:00:00 2001 From: Harrison Chase Date: Fri, 23 Aug 2024 13:34:33 -0700 Subject: [PATCH 08/30] docs: add how-to for dynamic interrupts (#1446) --------- Co-authored-by: vbarda --- docs/_scripts/copy_notebooks.py | 1 + docs/docs/how-tos/index.md | 1 + docs/mkdocs.yml | 1 + .../dynamic_breakpoints.ipynb | 417 ++++++++++++++++++ 4 files changed, 420 insertions(+) create mode 100644 examples/human_in_the_loop/dynamic_breakpoints.ipynb diff --git a/docs/_scripts/copy_notebooks.py b/docs/_scripts/copy_notebooks.py index 2440185f9..caf2c6781 100644 --- a/docs/_scripts/copy_notebooks.py +++ b/docs/_scripts/copy_notebooks.py @@ -56,6 +56,7 @@ _MANUAL = { "create-react-agent-memory.ipynb", "create-react-agent-hitl.ipynb", "human_in_the_loop/breakpoints.ipynb", + "human_in_the_loop/dynamic_breakpoints.ipynb", "human_in_the_loop/time-travel.ipynb", "human_in_the_loop/edit-graph-state.ipynb", "human_in_the_loop/wait-user-input.ipynb", diff --git a/docs/docs/how-tos/index.md b/docs/docs/how-tos/index.md index 09d5f2e8f..43b0c9265 100644 --- a/docs/docs/how-tos/index.md +++ b/docs/docs/how-tos/index.md @@ -35,6 +35,7 @@ One of LangGraph's main benefits is that it makes human-in-the-loop workflows ea These guides cover common examples of that. - [How to add breakpoints](human_in_the_loop/breakpoints.ipynb) +- [How to add dynamic breakpoints](human_in_the_loop/dynamic_breakpoints.ipynb) - [How to edit graph state](human_in_the_loop/edit-graph-state.ipynb) - [How to wait for user input](human_in_the_loop/wait-user-input.ipynb) - [How to view and update past graph state](human_in_the_loop/time-travel.ipynb) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 3bc3f3d24..1719b1cca 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -139,6 +139,7 @@ nav: - Create custom checkpointer using Redis: how-tos/persistence_redis.ipynb - Human-in-the-loop: - Add breakpoints: how-tos/human_in_the_loop/breakpoints.ipynb + - Add dynamic breakpoints: how-tos/human_in_the_loop/dynamic_breakpoints.ipynb - Wait for user input: how-tos/human_in_the_loop/wait-user-input.ipynb - View and update past graph state: how-tos/human_in_the_loop/time-travel.ipynb - Edit graph state: how-tos/human_in_the_loop/edit-graph-state.ipynb diff --git a/examples/human_in_the_loop/dynamic_breakpoints.ipynb b/examples/human_in_the_loop/dynamic_breakpoints.ipynb new file mode 100644 index 000000000..f43049d28 --- /dev/null +++ b/examples/human_in_the_loop/dynamic_breakpoints.ipynb @@ -0,0 +1,417 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ee54cde3-7e4d-43f4-b921-e7141ea0f19e", + "metadata": {}, + "source": [ + "# How to add dynamic breakpoints" + ] + }, + { + "cell_type": "markdown", + "id": "607849c6-4b8c-4e06-ad9c-758bb5a08e86", + "metadata": {}, + "source": [ + "Human-in-the-loop (HIL) interactions are crucial for [agentic systems](https://langchain-ai.github.io/langgraph/concepts/agentic_concepts/#human-in-the-loop). [Breakpoints](https://langchain-ai.github.io/langgraph/concepts/low_level/#breakpoints) are a common HIL interaction pattern, allowing the graph to stop at specific steps and seek human approval before proceeding (e.g., for sensitive actions).\n", + "\n", + "In LangGraph you can add breakpoints before / after a node is executed. But oftentimes it may be helpful to **dynamically** interrupt the graph from inside a given node based on some condition. When doing so, it may also be helpful to include information about **why** that interrupt was raised.\n", + "\n", + "This guide shows how you can dynamically interrupt the graph using `NodeInterupt` -- a special exception that can be raised from inside a node. Let's see it in action!" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2013d058-c245-498e-ba05-5af99b9b8a1b", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -U langgraph" + ] + }, + { + "cell_type": "markdown", + "id": "e9aa244f-1dd9-450e-9526-b1a28b30f84f", + "metadata": {}, + "source": [ + "### Define the graph" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "9a14c8b2-5c25-4201-93ea-e5358ee99bcb", + "metadata": {}, + "outputs": [ + { + "data": { + "image/jpeg": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from typing import TypedDict\n", + "from IPython.display import Image, display\n", + "\n", + "from langgraph.graph import StateGraph, START, END\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "from langgraph.errors import NodeInterrupt\n", + "\n", + "\n", + "class State(TypedDict):\n", + " input: str\n", + "\n", + "\n", + "def step_1(state: State) -> State:\n", + " print(\"---Step 1---\")\n", + " return state\n", + "\n", + "\n", + "def step_2(state: State) -> State:\n", + " # Let's optionally raise a NodeInterrupt\n", + " # if the length of the input is longer than 5 characters\n", + " if len(state['input']) > 5:\n", + " raise NodeInterrupt(f\"Received input that is longer than 5 characters: {state['input']}\")\n", + " \n", + " print(\"---Step 2---\")\n", + " return state\n", + "\n", + "def step_3(state: State) -> State:\n", + " print(\"---Step 3---\")\n", + " return state\n", + "\n", + "\n", + "builder = StateGraph(State)\n", + "builder.add_node(\"step_1\", step_1)\n", + "builder.add_node(\"step_2\", step_2)\n", + "builder.add_node(\"step_3\", step_3)\n", + "builder.add_edge(START, \"step_1\")\n", + "builder.add_edge(\"step_1\", \"step_2\")\n", + "builder.add_edge(\"step_2\", \"step_3\")\n", + "builder.add_edge(\"step_3\", END)\n", + "\n", + "# Set up memory\n", + "memory = MemorySaver()\n", + "\n", + "# Compile the graph with memory\n", + "graph = builder.compile(checkpointer=memory)\n", + "\n", + "# View\n", + "display(Image(graph.get_graph().draw_mermaid_png()))" + ] + }, + { + "cell_type": "markdown", + "id": "ad5521e1-0e58-42c5-9282-ff96f24ee6f6", + "metadata": {}, + "source": [ + "### Run the graph with dynamic interrupt" + ] + }, + { + "cell_type": "markdown", + "id": "83692c63-5c65-4562-9c65-5ad1935e339f", + "metadata": {}, + "source": [ + "First, let's run the graph with an input that <= 5 characters long. This should safely ignore the interrupt condition we defined and return the original input at the end of the graph execution." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b2d281f1-3349-4378-8918-7665fa7a7457", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'input': 'hello'}\n", + "---Step 1---\n", + "{'input': 'hello'}\n", + "---Step 2---\n", + "{'input': 'hello'}\n", + "---Step 3---\n", + "{'input': 'hello'}\n" + ] + } + ], + "source": [ + "initial_input = {\"input\": \"hello\"}\n", + "thread_config = {\"configurable\": {\"thread_id\": \"1\"}}\n", + "\n", + "for event in graph.stream(initial_input, thread_config, stream_mode=\"values\"):\n", + " print(event)" + ] + }, + { + "cell_type": "markdown", + "id": "2b66b926-47eb-401b-b37b-d80269d7214c", + "metadata": {}, + "source": [ + "If we inspect the graph at this point, we can see that there are no more tasks left to run and that the graph indeed finished execution." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "4eac1455-e7ef-4a32-8c14-0d5789409689", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "()\n", + "()\n" + ] + } + ], + "source": [ + "state = graph.get_state(thread_config)\n", + "print(state.next)\n", + "print(state.tasks)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "f8e03817-2135-4fb3-b881-fd6d2c378ccf", + "metadata": {}, + "source": [ + "Now, let's run the graph with an input that's longer than 5 characters. This should trigger the dynamic interrupt we defined via raising a `NodeInterrupt` error inside the `step_2` node." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c06192ad-13a4-4d2e-8e30-f1c08578fe77", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'input': 'hello world'}\n", + "---Step 1---\n", + "{'input': 'hello world'}\n" + ] + } + ], + "source": [ + "initial_input = {\"input\": \"hello world\"}\n", + "thread_config = {\"configurable\": {\"thread_id\": \"2\"}}\n", + "\n", + "# Run the graph until the first interruption\n", + "for event in graph.stream(initial_input, thread_config, stream_mode=\"values\"):\n", + " print(event)" + ] + }, + { + "cell_type": "markdown", + "id": "173fd4f1-db97-44bb-a9e5-435ed042e3a3", + "metadata": {}, + "source": [ + "We can see that the graph now stopped while executing `step_2`. If we inspect the graph state at this point, we can see the information on what node is set to execute next (`step_2`), as well as what node raised the interrupt (also `step_2`), and additional information about the interrupt." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2058593c-178e-4a23-a4c4-860d4a9c2198", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "('step_2',)\n", + "(PregelTask(id='365d4518-bcff-5abd-8ef5-8a0de7f510b0', name='step_2', error=None, interrupts=(Interrupt(value='Received input that is longer than 5 characters: hello world', when='during'),)),)\n" + ] + } + ], + "source": [ + "state = graph.get_state(thread_config)\n", + "print(state.next)\n", + "print(state.tasks)" + ] + }, + { + "cell_type": "markdown", + "id": "fc36d1be-ae2e-49c8-a17f-2b27be09618a", + "metadata": {}, + "source": [ + "If we try to resume the graph from the breakpoint, we will simply interrupt again as our inputs & graph state haven't changed." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "872e7a69-9784-4f81-90c6-6b6af2fa6480", + "metadata": {}, + "outputs": [], + "source": [ + "# NOTE: to resume the graph from a dynamic interrupt we use the same syntax as with regular interrupts -- we pass None as the input\n", + "for event in graph.stream(None, thread_config, stream_mode=\"values\"):\n", + " print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3275f899-7039-4029-8814-0bb5c33fabfe", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "('step_2',)\n", + "(PregelTask(id='365d4518-bcff-5abd-8ef5-8a0de7f510b0', name='step_2', error=None, interrupts=(Interrupt(value='Received input that is longer than 5 characters: hello world', when='during'),)),)\n" + ] + } + ], + "source": [ + "state = graph.get_state(thread_config)\n", + "print(state.next)\n", + "print(state.tasks)" + ] + }, + { + "cell_type": "markdown", + "id": "a5862dea-2af2-48cb-9889-979b6c6af6aa", + "metadata": {}, + "source": [ + "### Update the graph state" + ] + }, + { + "cell_type": "markdown", + "id": "c8724ef6-877a-44b9-b96a-ae81efa2d9e4", + "metadata": {}, + "source": [ + "To get around it, we can do several things. \n", + "\n", + "First, we could simply run the graph on a different thread with a shorter input, like we did in the beginning. Alternatively, if we want to resume the graph execution from the breakpoint, we can update the state to have an input that's shorter than 5 characters (the condition for our interrupt)." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2ba8dc8d-b90e-45f5-92cd-2192fc66f270", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---Step 2---\n", + "{'input': 'foo'}\n", + "---Step 3---\n", + "{'input': 'foo'}\n", + "()\n", + "{'input': 'foo'}\n" + ] + } + ], + "source": [ + "# NOTE: this update will be applied as of the last successful node before the interrupt, i.e. `step_1`, right before the node with an interrupt\n", + "graph.update_state(config=thread_config, values={\"input\": \"foo\"})\n", + "for event in graph.stream(None, thread_config, stream_mode=\"values\"):\n", + " print(event)\n", + "\n", + "state = graph.get_state(thread_config)\n", + "print(state.next)\n", + "print(state.values)" + ] + }, + { + "cell_type": "markdown", + "id": "6f16980e-aef4-45c9-85eb-955568a93c5b", + "metadata": {}, + "source": [ + "You can also update the state **as node `step_2`** (interrupted node) which would skip over that node altogether" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "9a48e564-d979-4ac2-b815-c667345a9f07", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'input': 'hello world'}\n", + "---Step 1---\n", + "{'input': 'hello world'}\n" + ] + } + ], + "source": [ + "initial_input = {\"input\": \"hello world\"}\n", + "thread_config = {\"configurable\": {\"thread_id\": \"3\"}}\n", + "\n", + "# Run the graph until the first interruption\n", + "for event in graph.stream(initial_input, thread_config, stream_mode=\"values\"):\n", + " print(event)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "17f973ab-00ce-4f16-a452-641e76625fde", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "---Step 3---\n", + "{'input': 'hello world'}\n", + "()\n", + "{'input': 'hello world'}\n" + ] + } + ], + "source": [ + "# NOTE: this update will skip the node `step_2` altogether\n", + "graph.update_state(config=thread_config, values=None, as_node=\"step_2\")\n", + "for event in graph.stream(None, thread_config, stream_mode=\"values\"):\n", + " print(event)\n", + "\n", + "state = graph.get_state(thread_config)\n", + "print(state.next)\n", + "print(state.values)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "langgraph", + "language": "python", + "name": "langgraph" + }, + "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.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From bc482431c3158008bb50d2f687ace64d07788f0c Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 13:35:21 -0700 Subject: [PATCH 09/30] lib0.2.13 --- libs/langgraph/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index bc0b67fb4..a447f6d3c 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.12" +version = "0.2.13" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" From 610257665f6f29495b265370e78f40288707e280 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Aug 2024 20:36:31 +0000 Subject: [PATCH 10/30] Bump micromatch from 4.0.7 to 4.0.8 in /libs/sdk-js Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.7 to 4.0.8. - [Release notes](https://github.com/micromatch/micromatch/releases) - [Changelog](https://github.com/micromatch/micromatch/blob/4.0.8/CHANGELOG.md) - [Commits](https://github.com/micromatch/micromatch/compare/4.0.7...4.0.8) --- updated-dependencies: - dependency-name: micromatch dependency-type: indirect ... Signed-off-by: dependabot[bot] --- libs/sdk-js/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/sdk-js/yarn.lock b/libs/sdk-js/yarn.lock index 9c46e6ca6..b4e6f252a 100644 --- a/libs/sdk-js/yarn.lock +++ b/libs/sdk-js/yarn.lock @@ -1219,9 +1219,9 @@ micromark@^2.11.3, micromark@~2.11.0, micromark@~2.11.3: parse-entities "^2.0.0" micromatch@^4.0.4: - version "4.0.7" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.7.tgz#33e8190d9fe474a9895525f5618eee136d46c2e5" - integrity sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q== + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: braces "^3.0.3" picomatch "^2.3.1" From 134f8faf8c4d095fb3ca3df815d243a4ea806bc0 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Fri, 23 Aug 2024 17:14:16 -0700 Subject: [PATCH 11/30] Add section about authentication to API reference. (#1458) --- docs/docs/cloud/reference/api/api_ref.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/docs/cloud/reference/api/api_ref.md b/docs/docs/cloud/reference/api/api_ref.md index 0cac27c63..7c2a085f2 100644 --- a/docs/docs/cloud/reference/api/api_ref.md +++ b/docs/docs/cloud/reference/api/api_ref.md @@ -3,3 +3,20 @@ The LangGraph Cloud API reference is available with each deployment at the `/docs` URL path (e.g. `http://localhost:8124/docs`). Click here to view the API reference. + +## Authentication + +For deployments to LangGraph Cloud, authentication is required. Pass the `X-Api-Key` header with each request to the LangGraph Cloud API. The value of the header should be set to a valid LangSmith API key for the organization where the API is deployed. + +Example `curl` command: +```shell +curl --request POST \ + --url http://localhost:8124/assistants/search \ + --header 'Content-Type: application/json' \ + --header 'X-Api-Key: LANGSMITH_API_KEY' \ + --data '{ + "metadata": {}, + "limit": 10, + "offset": 0 +}' +``` From 3a524e0e562207b342c98ca58ed8d9147c806c1c Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 17:55:14 -0700 Subject: [PATCH 12/30] Fix attributeerror --- libs/langgraph/langgraph/managed/base.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/libs/langgraph/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py index ca206464e..fbedf1f3e 100644 --- a/libs/langgraph/langgraph/managed/base.py +++ b/libs/langgraph/langgraph/managed/base.py @@ -117,10 +117,13 @@ class ManagedValueMapping(dict[str, ManagedValue]): values[key] = {RUNTIME_PLACEHOLDER: chan} elif hasattr(values, "__dir__") and callable(values.__dir__): for key in dir(values): - value = getattr(values, key) - for chan, mv in self.items(): - if mv.runtime and mv(step) is value: - setattr(values, key, {RUNTIME_PLACEHOLDER: chan}) + try: + value = getattr(values, key) + for chan, mv in self.items(): + if mv.runtime and mv(step) is value: + setattr(values, key, {RUNTIME_PLACEHOLDER: chan}) + except AttributeError: + pass def replace_runtime_placeholders( self, step: int, values: Union[dict[str, Any], Any] @@ -131,6 +134,9 @@ class ManagedValueMapping(dict[str, ManagedValue]): values[key] = self[value[RUNTIME_PLACEHOLDER]](step) elif hasattr(values, "__dir__") and callable(values.__dir__): for key in dir(values): - value = getattr(values, key) - if isinstance(value, dict) and RUNTIME_PLACEHOLDER in value: - setattr(values, key, self[value[RUNTIME_PLACEHOLDER]](step)) + try: + value = getattr(values, key) + if isinstance(value, dict) and RUNTIME_PLACEHOLDER in value: + setattr(values, key, self[value[RUNTIME_PLACEHOLDER]](step)) + except AttributeError: + pass From 3ac4cdf3d40b034cdf481da68809dd519c9cebb3 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:14:40 -0700 Subject: [PATCH 13/30] Fix pydantic model deserialization --- libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index 608a077cd..d52bfc8c1 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -51,9 +51,13 @@ class JsonPlusSerializer(SerializerProtocol): if isinstance(obj, Serializable): return obj.to_json() elif hasattr(obj, "model_dump") and callable(obj.model_dump): - return self._encode_constructor_args(obj.__class__, kwargs=obj.model_dump()) + return self._encode_constructor_args( + obj.__class__, method="model_construct", kwargs=obj.model_dump() + ) elif hasattr(obj, "dict") and callable(obj.dict): - return self._encode_constructor_args(obj.__class__, kwargs=obj.dict()) + return self._encode_constructor_args( + obj.__class__, method="construct", kwargs=obj.dict() + ) elif isinstance(obj, pathlib.Path): return self._encode_constructor_args(pathlib.Path, args=obj.parts) elif isinstance(obj, re.Pattern): From a8d860273b35166ebfe2cbc2844b7e02522e33c0 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:14:59 -0700 Subject: [PATCH 14/30] Skip runtime value replacement when not needed --- libs/langgraph/langgraph/managed/base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/libs/langgraph/langgraph/managed/base.py b/libs/langgraph/langgraph/managed/base.py index fbedf1f3e..5ce15aec2 100644 --- a/libs/langgraph/langgraph/managed/base.py +++ b/libs/langgraph/langgraph/managed/base.py @@ -110,6 +110,10 @@ ChannelTypePlaceholder = object() class ManagedValueMapping(dict[str, ManagedValue]): def replace_runtime_values(self, step: int, values: Union[dict[str, Any], Any]): + if not self or not values: + return + if all(not mv.runtime for mv in self.values()): + return if isinstance(values, dict): for key, value in values.items(): for chan, mv in self.items(): @@ -128,6 +132,10 @@ class ManagedValueMapping(dict[str, ManagedValue]): def replace_runtime_placeholders( self, step: int, values: Union[dict[str, Any], Any] ): + if not self or not values: + return + if all(not mv.runtime for mv in self.values()): + return if isinstance(values, dict): for key, value in values.items(): if isinstance(value, dict) and RUNTIME_PLACEHOLDER in value: From 58e139e7fd4122c4656d0099d4a2938769f461d6 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:15:04 -0700 Subject: [PATCH 15/30] Lint --- libs/langgraph/langgraph/pregel/algo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index a1dd5d536..29adcef5e 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -141,7 +141,6 @@ def local_write( raise InvalidUpdateError(f"Invalid node name {value.node} in packet") # replace any runtime values with placeholders managed.replace_runtime_values(step, value.arg) - print("after replace", value) elif chan not in channels and chan not in managed: logger.warning(f"Skipping write for channel '{chan}' which has no readers") commit(writes) From 66a13b886512d1590b8f075ac4e4ef6cd2b94929 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:15:15 -0700 Subject: [PATCH 16/30] Add test for runtime value replacement with pydantic model --- libs/langgraph/tests/test_pregel_async.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 4995addb6..f59433a07 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -21,6 +21,9 @@ from uuid import UUID import httpx import pytest +from langchain_core.messages import ( + ToolCall, +) from langchain_core.runnables import ( RunnableConfig, RunnableLambda, @@ -3877,6 +3880,12 @@ async def test_prebuilt_tool_chat() -> None: ] +# defined outside to allow deserializer to see it +class ToolInput(BaseModel, arbitrary_types_allowed=True): + call: ToolCall + my_session: httpx.AsyncClient + + async def test_state_graph_packets() -> None: from langchain_core.language_models.fake_chat_models import ( FakeMessagesListChatModel, @@ -3885,7 +3894,6 @@ async def test_state_graph_packets() -> None: AIMessage, BaseMessage, HumanMessage, - ToolCall, ToolMessage, ) from langchain_core.tools import tool @@ -3941,19 +3949,15 @@ async def test_state_graph_packets() -> None: # Logic to decide whether to continue in the loop or exit if tool_calls := data["messages"][-1].tool_calls: return [ - Send("tools", {"call": tool_call, "my_session": data["session"]}) + Send("tools", ToolInput(call=tool_call, my_session=data["session"])) for tool_call in tool_calls ] else: return END - class ToolInput(TypedDict): - call: ToolCall - my_session: httpx.Client - async def tools_node(input: ToolInput, config: RunnableConfig) -> AgentState: - assert isinstance(input["my_session"], httpx.AsyncClient) - tool_call = input["call"] + assert isinstance(input.my_session, httpx.AsyncClient) + tool_call = input.call await asyncio.sleep(tool_call["args"].get("idx", 0) / 10) output = await tools_by_name[tool_call["name"]].ainvoke( tool_call["args"], config From 3cda14b0694e953127300c93a3e1013986efa9f6 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:15:31 -0700 Subject: [PATCH 17/30] lib 0.2.14 --- libs/langgraph/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index a447f6d3c..35164c2f4 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.13" +version = "0.2.14" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" From c49692d7949b9c3c4d9d0882b7e3d7accd3fd483 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:15:48 -0700 Subject: [PATCH 18/30] checkpoint 1.0.5 --- libs/checkpoint/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index 7ceea436e..cacd30eca 100644 --- a/libs/checkpoint/pyproject.toml +++ b/libs/checkpoint/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-checkpoint" -version = "1.0.4" +version = "1.0.5" description = "Library with base interfaces for LangGraph checkpoint savers." authors = [] license = "MIT" From ef790a57c6cd67a70f3efceb20040e0efe40fa61 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:17:51 -0700 Subject: [PATCH 19/30] Lint --- libs/checkpoint/tests/test_jsonplus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index d5a53d248..ce604ed06 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -122,7 +122,7 @@ def test_serde_jsonplus() -> None: assert dumped == ( "json", - b"""{"path": {"lc": 2, "type": "constructor", "id": ["pathlib", "Path"], "method": null, "args": ["foo", "bar"], "kwargs": {}}, "re": {"lc": 2, "type": "constructor", "id": ["re", "compile"], "method": null, "args": ["foo", 48], "kwargs": {}}, "decimal": {"lc": 2, "type": "constructor", "id": ["decimal", "Decimal"], "method": null, "args": ["1.10101"], "kwargs": {}}, "ip4": {"lc": 2, "type": "constructor", "id": ["ipaddress", "IPv4Address"], "method": null, "args": ["192.168.0.1"], "kwargs": {}}, "deque": {"lc": 2, "type": "constructor", "id": ["collections", "deque"], "method": null, "args": [[1, 2, 3]], "kwargs": {}}, "tzn": {"lc": 2, "type": "constructor", "id": ["zoneinfo", "ZoneInfo"], "method": null, "args": ["America/New_York"], "kwargs": {}}, "date": {"lc": 2, "type": "constructor", "id": ["datetime", "date"], "method": null, "args": [2024, 4, 19], "kwargs": {}}, "time": {"lc": 2, "type": "constructor", "id": ["datetime", "time"], "method": null, "args": [23, 4, 57, 51022, {"lc": 2, "type": "constructor", "id": ["datetime", "timezone"], "method": null, "args": [{"lc": 2, "type": "constructor", "id": ["datetime", "timedelta"], "method": null, "args": [0, 86340, 0], "kwargs": {}}], "kwargs": {}}], "kwargs": {"fold": 0}}, "uid": {"lc": 2, "type": "constructor", "id": ["uuid", "UUID"], "method": null, "args": ["00000000000000000000000000000001"], "kwargs": {}}, "timestamp": {"lc": 2, "type": "constructor", "id": ["datetime", "datetime"], "method": "fromisoformat", "args": ["2024-04-19T23:04:57.051022+23:59"], "kwargs": {}}, "my_slotted_class": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyDataclassWSlots"], "method": null, "args": [], "kwargs": {"foo": "bar", "bar": 2}}, "my_dataclass": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyDataclass"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "my_enum": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyEnum"], "method": null, "args": ["foo"], "kwargs": {}}, "my_pydantic": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyPydantic"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "my_funny_pydantic": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyFunnyPydantic"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "person": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "Person"], "method": null, "args": [], "kwargs": {"name": "foo"}}, "a_bool": true, "a_none": null, "a_str": "foo", "a_str_nuc": "foo\\u0000", "a_str_uc": "foo \xe2\x9b\xb0\xef\xb8\x8f", "a_str_ucuc": "foo \xe2\x9b\xb0\xef\xb8\x8f\\u0000", "a_str_ucucuc": "foo \\\\u26f0\\\\ufe0f", "text": ["Hello", "Python", "Surrogate", "Example", "String", "With", "Surrogates", "Embedded", "In", "The", "Text", "\xe6\x94\xb6\xe8\x8a\xb1\xf0\x9f\x99\x84\xc2\xb7\xe5\x88\xb0"], "an_int": 1, "a_float": 1.1, "runnable_map": {"lc": 1, "type": "constructor", "id": ["langchain", "schema", "runnable", "RunnableParallel"], "kwargs": {"steps__": {}}, "name": "RunnableParallel<>", "graph": {"nodes": [{"id": 0, "type": "schema", "data": "Parallel<>Input"}, {"id": 1, "type": "schema", "data": "Parallel<>Output"}], "edges": []}}}""", + b"""{"path": {"lc": 2, "type": "constructor", "id": ["pathlib", "Path"], "method": null, "args": ["foo", "bar"], "kwargs": {}}, "re": {"lc": 2, "type": "constructor", "id": ["re", "compile"], "method": null, "args": ["foo", 48], "kwargs": {}}, "decimal": {"lc": 2, "type": "constructor", "id": ["decimal", "Decimal"], "method": null, "args": ["1.10101"], "kwargs": {}}, "ip4": {"lc": 2, "type": "constructor", "id": ["ipaddress", "IPv4Address"], "method": null, "args": ["192.168.0.1"], "kwargs": {}}, "deque": {"lc": 2, "type": "constructor", "id": ["collections", "deque"], "method": null, "args": [[1, 2, 3]], "kwargs": {}}, "tzn": {"lc": 2, "type": "constructor", "id": ["zoneinfo", "ZoneInfo"], "method": null, "args": ["America/New_York"], "kwargs": {}}, "date": {"lc": 2, "type": "constructor", "id": ["datetime", "date"], "method": null, "args": [2024, 4, 19], "kwargs": {}}, "time": {"lc": 2, "type": "constructor", "id": ["datetime", "time"], "method": null, "args": [23, 4, 57, 51022, {"lc": 2, "type": "constructor", "id": ["datetime", "timezone"], "method": null, "args": [{"lc": 2, "type": "constructor", "id": ["datetime", "timedelta"], "method": null, "args": [0, 86340, 0], "kwargs": {}}], "kwargs": {}}], "kwargs": {"fold": 0}}, "uid": {"lc": 2, "type": "constructor", "id": ["uuid", "UUID"], "method": null, "args": ["00000000000000000000000000000001"], "kwargs": {}}, "timestamp": {"lc": 2, "type": "constructor", "id": ["datetime", "datetime"], "method": "fromisoformat", "args": ["2024-04-19T23:04:57.051022+23:59"], "kwargs": {}}, "my_slotted_class": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyDataclassWSlots"], "method": null, "args": [], "kwargs": {"foo": "bar", "bar": 2}}, "my_dataclass": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyDataclass"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "my_enum": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyEnum"], "method": null, "args": ["foo"], "kwargs": {}}, "my_pydantic": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyPydantic"], "method": "model_construct", "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "my_funny_pydantic": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyFunnyPydantic"], "method": "construct", "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "person": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "Person"], "method": null, "args": [], "kwargs": {"name": "foo"}}, "a_bool": true, "a_none": null, "a_str": "foo", "a_str_nuc": "foo\\u0000", "a_str_uc": "foo \xe2\x9b\xb0\xef\xb8\x8f", "a_str_ucuc": "foo \xe2\x9b\xb0\xef\xb8\x8f\\u0000", "a_str_ucucuc": "foo \\\\u26f0\\\\ufe0f", "text": ["Hello", "Python", "Surrogate", "Example", "String", "With", "Surrogates", "Embedded", "In", "The", "Text", "\xe6\x94\xb6\xe8\x8a\xb1\xf0\x9f\x99\x84\xc2\xb7\xe5\x88\xb0"], "an_int": 1, "a_float": 1.1, "runnable_map": {"lc": 1, "type": "constructor", "id": ["langchain", "schema", "runnable", "RunnableParallel"], "kwargs": {"steps__": {}}, "name": "RunnableParallel<>", "graph": {"nodes": [{"id": 0, "type": "schema", "data": "Parallel<>Input"}, {"id": 1, "type": "schema", "data": "Parallel<>Output"}], "edges": []}}}""", ) assert serde.loads_typed(dumped) == { From 4a4dd16535fe4669b76b3964194a8e24422843c4 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Fri, 23 Aug 2024 18:25:56 -0700 Subject: [PATCH 20/30] checkpoint 1.0.6 --- libs/checkpoint/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index cacd30eca..0cdafc185 100644 --- a/libs/checkpoint/pyproject.toml +++ b/libs/checkpoint/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-checkpoint" -version = "1.0.5" +version = "1.0.6" description = "Library with base interfaces for LangGraph checkpoint savers." authors = [] license = "MIT" From b7744aff9bf32807478b7207039257e9340f7750 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Sat, 24 Aug 2024 19:24:13 +0200 Subject: [PATCH 21/30] feat(sdk-js): add apiKey property --- libs/sdk-js/src/client.mts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/sdk-js/src/client.mts b/libs/sdk-js/src/client.mts index fbf2dd12c..96654f0f4 100644 --- a/libs/sdk-js/src/client.mts +++ b/libs/sdk-js/src/client.mts @@ -24,6 +24,7 @@ import { interface ClientConfig { apiUrl?: string; + apiKey?: string; callerOptions?: AsyncCallerParams; timeoutMs?: number; defaultHeaders?: Record; @@ -48,6 +49,9 @@ class BaseClient { this.timeoutMs = config?.timeoutMs || 12_000; this.apiUrl = config?.apiUrl || "http://localhost:8123"; this.defaultHeaders = config?.defaultHeaders || {}; + if (config?.apiKey != null) { + this.defaultHeaders["X-Api-Key"] = config.apiKey; + } } protected prepareFetchOptions( From 4b48e71d2c031bdb4fece0b2d412066433929d46 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Sat, 24 Aug 2024 19:24:39 +0200 Subject: [PATCH 22/30] Bump to 0.0.7 --- libs/sdk-js/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/sdk-js/package.json b/libs/sdk-js/package.json index a10bcdab8..659b087ca 100644 --- a/libs/sdk-js/package.json +++ b/libs/sdk-js/package.json @@ -1,6 +1,6 @@ { "name": "@langchain/langgraph-sdk", - "version": "0.0.6", + "version": "0.0.7", "description": "Client library for interacting with the LangGraph API", "type": "module", "packageManager": "yarn@1.22.19", From 03785c7d83f764bde65d067c26c5b6dcd4ef50b7 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Sat, 24 Aug 2024 21:34:35 -0700 Subject: [PATCH 23/30] sdk: Add on_completion param --- libs/sdk-py/langgraph_sdk/client.py | 10 ++++++++++ libs/sdk-py/langgraph_sdk/schema.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/libs/sdk-py/langgraph_sdk/client.py b/libs/sdk-py/langgraph_sdk/client.py index 24b2ef3b0..8578680d7 100644 --- a/libs/sdk-py/langgraph_sdk/client.py +++ b/libs/sdk-py/langgraph_sdk/client.py @@ -29,6 +29,7 @@ from langgraph_sdk.schema import ( GraphSchema, Metadata, MultitaskStrategy, + OnCompletionBehavior, OnConflictBehavior, Run, RunCreate, @@ -985,6 +986,7 @@ class RunsClient: feedback_keys: Optional[list[str]] = None, on_disconnect: Optional[DisconnectMode] = None, webhook: Optional[str] = None, + on_completion: Optional[OnCompletionBehavior] = None, ) -> AsyncIterator[StreamPart]: ... @@ -1004,6 +1006,7 @@ class RunsClient: on_disconnect: Optional[DisconnectMode] = None, webhook: Optional[str] = None, multitask_strategy: Optional[MultitaskStrategy] = None, + on_completion: Optional[OnCompletionBehavior] = None, ) -> AsyncIterator[StreamPart]: """Create a run and stream the results. @@ -1070,6 +1073,7 @@ class RunsClient: "checkpoint_id": checkpoint_id, "multitask_strategy": multitask_strategy, "on_disconnect": on_disconnect, + "on_completion": on_completion, } endpoint = ( f"/threads/{thread_id}/runs/stream" @@ -1092,6 +1096,7 @@ class RunsClient: interrupt_before: Optional[list[str]] = None, interrupt_after: Optional[list[str]] = None, webhook: Optional[str] = None, + on_completion: Optional[OnCompletionBehavior] = None, ) -> Run: ... @@ -1125,6 +1130,7 @@ class RunsClient: interrupt_after: Optional[list[str]] = None, webhook: Optional[str] = None, multitask_strategy: Optional[MultitaskStrategy] = None, + on_completion: Optional[OnCompletionBehavior] = None, ) -> Run: """Create a background run. @@ -1221,6 +1227,7 @@ class RunsClient: "webhook": webhook, "checkpoint_id": checkpoint_id, "multitask_strategy": multitask_strategy, + "on_completion": on_completion, } payload = {k: v for k, v in payload.items() if v is not None} if thread_id: @@ -1268,6 +1275,7 @@ class RunsClient: interrupt_after: Optional[list[str]] = None, webhook: Optional[str] = None, on_disconnect: Optional[DisconnectMode] = None, + on_completion: Optional[OnCompletionBehavior] = None, ) -> Union[list[dict], dict[str, Any]]: ... @@ -1285,6 +1293,7 @@ class RunsClient: webhook: Optional[str] = None, on_disconnect: Optional[DisconnectMode] = None, multitask_strategy: Optional[MultitaskStrategy] = None, + on_completion: Optional[OnCompletionBehavior] = None, ) -> Union[list[dict], dict[str, Any]]: """Create a run, wait until it finishes and return the final state. @@ -1364,6 +1373,7 @@ class RunsClient: "checkpoint_id": checkpoint_id, "multitask_strategy": multitask_strategy, "on_disconnect": on_disconnect, + "on_completion": on_completion, } endpoint = ( f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait" diff --git a/libs/sdk-py/langgraph_sdk/schema.py b/libs/sdk-py/langgraph_sdk/schema.py index c3232c88e..29fa903a9 100644 --- a/libs/sdk-py/langgraph_sdk/schema.py +++ b/libs/sdk-py/langgraph_sdk/schema.py @@ -15,6 +15,8 @@ MultitaskStrategy = Literal["reject", "interrupt", "rollback", "enqueue"] OnConflictBehavior = Literal["raise", "do_nothing"] +OnCompletionBehavior = Literal["delete", "keep"] + All = Literal["*"] From a2cfe694f189e654616ccb338055a28a3027cb53 Mon Sep 17 00:00:00 2001 From: William FH <13333726+hinthornw@users.noreply.github.com> Date: Sun, 25 Aug 2024 20:47:05 -0700 Subject: [PATCH 24/30] Fix import (#1472) --- examples/docs/quickstart.ipynb | 143 ++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 30 deletions(-) diff --git a/examples/docs/quickstart.ipynb b/examples/docs/quickstart.ipynb index e8d621a80..84d73c024 100644 --- a/examples/docs/quickstart.ipynb +++ b/examples/docs/quickstart.ipynb @@ -12,30 +12,55 @@ "execution_count": 13, "metadata": {}, "outputs": [], - "source": ["%%capture --no-stderr\n%pip install --quiet -U langgraph langchain-openai"] + "source": [ + "%%capture --no-stderr\n", + "%pip install --quiet -U langgraph langchain-openai" + ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 1, "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\nif not os.environ.get(\"OPENAI_API_KEY\"):\n os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")"] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "if not os.environ.get(\"OPENAI_API_KEY\"):\n", + " os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")" + ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 2, "metadata": {}, "outputs": [], - "source": ["from langchain_core.messages import BaseMessage, HumanMessage\nfrom langchain_openai import ChatOpenAI\n\nfrom langgraph.graph import END, MessageGraph\n\nmodel = ChatOpenAI(temperature=0)\n\ngraph = MessageGraph()\n\ngraph.add_node(\"oracle\", model)\ngraph.add_edge(\"oracle\", END)\n\ngraph.add_edge(START, \"oracle\")\n\nrunnable = graph.compile()"] + "source": [ + "from langchain_core.messages import BaseMessage, HumanMessage\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "from langgraph.graph import START, END, MessageGraph\n", + "\n", + "model = ChatOpenAI(temperature=0)\n", + "\n", + "graph = MessageGraph()\n", + "\n", + "graph.add_node(\"oracle\", model)\n", + "graph.add_edge(\"oracle\", END)\n", + "\n", + "graph.add_edge(START, \"oracle\")\n", + "\n", + "runnable = graph.compile()" + ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 3, "metadata": {}, "outputs": [ { "data": { - "image/jpeg": "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", + "image/jpeg": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCADbAGsDASIAAhEBAxEB/8QAHQABAAEFAQEBAAAAAAAAAAAAAAYDBAUHCAIBCf/EAE8QAAEDAwEDBQkLBwoHAAAAAAECAwQABREGBxIhCBMWMUEUFSJRVWGU0dMXIzI3QlZxdoGRtFJUdJOVsdIJNDZDU2J1g5KzJSYzY3LBw//EABoBAQEAAwEBAAAAAAAAAAAAAAABAgMEBQf/xAA0EQACAQIEAgYKAQUAAAAAAAAAAQIDEQQSITFRoRMUUnGBsRUiIzNBU2GRwdHhBTJCsvH/2gAMAwEAAhEDEQA/AP1TpSsFdrtLk3AWi0hIlhIXJmODebiIPVw+U4r5KeoAFSuG6lecYubsi7mZfkNRmy484hpA61LUEgfaax51TZQcG7wAf0lHrqwZ2f2UrD1wii9zMYVKuoD6zxzwBG6j6EJSPNV8NK2UDHeeBj9FR6q22ordtjQ+9KrL5Ygeko9dOlVl8sQPSUeunRWy+R4HoyPVTorZfI8D0ZHqp7H68i6DpVZfLED0lHrp0qsvliB6Sj106K2XyPA9GR6qdFbL5HgejI9VPY/XkNB0qsvliB6Sj106VWXyxA9JR66dFbL5HgejI9VOitl8jwPRkeqnsfryGhcw7tBuBIizI8kjsZdSv9xq7rBTNCacnj36x29SuxxMZCVp86VAAg+cGrN1EzRYL6X5N0sYPvzT6ucfhp/LQr4TiB1lKipQGSCcBNMkJ6QevB/v/hLJ7EppXltxDzaXG1JWhQCkqScgg9RBr1XOQpyH0RmHHnDhDaStR8QAyawGz9lR0xFuDwHdl1HfGQoZ4rcAIHH8lO4geZArNXKJ3fbpUXOOfaW3nxZBH/usVoKV3XouyrIKXERG2nEqGClxA3FpI8ykkfZXQtKLtxX5L8DPUpSuchHddbQdP7NbGLvqS4C3QVPIjNqDS3XHXVnCG2220qWtRwcJSCeB8Va31lyptM6YnbP1RmZ9ztOqpEpszI9smLcjoZbdKiGUMKWpfONhBRgKA3lEYSTWb5QtptF20REF3tWpbgI9yYkxJOko6nrhbpCAoolNpTk+DxBwlXw8FJBNajM7aC7p7Y/rfVunr1eJOntQzzNah2z/AImuC7Hkx48l2I3kpWQtsrQkZG9nA4gAbn1nygtBbPbnHgahvi7ZIejtyvfIElTbLSyQhby0tlLIJBGXCnqPiqvqfbnorR+pkaduV3d7+ORGpzcCHAky3XGHFrQlxKWW17yctqyR8HAKsAgnQu3Mar2gXHWttl2jXr9quenGkaUtdiZejRXXno6+e74LSUhK0uFKS0+oJ3AcJUSamGxTT90TtdgXqbZLjCY9zezQO6Z0JxnckJffLrBKkjDifAKkdY8E9ooCYbLeUFatpmttX6aagz4UyyXR2CytyBKDT7bbTSlOKdUylttW84oBsq3iEhQyFA1tetH7J5Fw0Xtf2kaeuenr0lGoNQKvVvvDUFbluWwqEwkhUgDdQsKYUndVgklOM5reFAKUpQEY0NiC1dbInAatEwxo6U5wlhTaHWkjPYlLgQPMipPUZ0knui9apnpzzT1wDLZIxkNMttqPn8MOD7Kk1dFf3jfdfvtrzK9xUXeCtG3KVLDal2Ka4XpHNpKlQ3jjecIH9UrGVEfAVlRylSlIlFK1wnlunqmCK6o2e6M2oMQJOoNP2bVDLCVKiOzorclKErxvFBUDgK3U5x14FYEcm3ZQElPub6W3SQSO9LGCez5PnNSWToK1uPuPw1S7O84SVqtklbCVEnJJbB3CSeOSnPXx4mqXQmR2apvw/wA5n2VbMlJ7St3r9XGh40hso0Xs/mPy9M6Us9glPt8069bYTbC1ozndJSBkZAOKldRfoTI+dV+/XM+yp0JkfOq/frmfZU6On2+TFlxJRSufdsV61DoTaJsoslt1PdFQ9T3h2DOL6mlLDaWSsbhDY3TntINba6EyPnVfv1zPsqdHT7fJiy4mX1Bp216rs8m03q3RrrbJIAehzGkutOAEKAUlQIOCAfpAqEo5N2ylsko2caXSSCMi0sDgRgj4PiNZ/oTI+dV+/XM+yp0JkfOq/frmfZU6On2+TFlxMTaNgOzSwXSLcrboHTkC4RXEvMSo1sZQ40sHIUlQTkEHtFZ67X9yTJctNkW3Iuud1134TUFJ61u/3sfBb61HHUneUm3OgmZHCbeb1PbPAtOTlNJV9PNbmR5uo9tZ63WyJaIiIsKM1EjpyQ2ygJGT1nh2ntPbT2cNU8z5DRHizWmPYrVFt8UKDEdAQkrO8pXjUo9qickntJJq9pStDbk7vcgpSlQClKUApSlAc78pb46OT39ZZH4Y10RXO/KW+Ojk9/WWR+GNdEUApSlAKUpQClKUApSlAKUpQClKUBzvylvjo5Pf1lkfhjXRFc78pb46OT39ZZH4Y10RQClKUApSlAKUpQClKUApXh11DDS3XVpbbQkqUtZwEgdZJ7BUN6W366oTKtNrhIgOAKZXcZDjbriT1KLaWzuAjBAJ3sHiEnIrdTpSqf2ltcmtWt0tcS92yZbp7CJUGYyuPIYcGUuNrSUqSR4iCR9tRLv7rD8wsfpb3s6d/dYfmFj9Le9nW7qs+K+6Fj8XuUTsdmbC9r+oNJSUrMaO8XYD7n9fEXxaXnGCd3grHAKSodlfq1yHdjcjYtyfbRAnhbd2vDir1NYWCCy46hAS3g9RS222FD8rerB7ZuTy7tu17onVV7gWZEzTb++ppD7ikz2QrfSw7lr4AWM/QpY+Vkbj7+6w/MLH6W97OnVZ8V90LE3pUI7+6w/MLH6W97Ovov2sAcm3WRQHyRMeTnzZ5o4+nBp1WfFfdCxNqVidPagRfWHgplUSbGXzUmKs5LasAgg/KSQQQodfaAQQMtXLKLg8styClKViBSlKAj20VRRs+1OoHBFrlEH/AClVbwgBDYAGAG08B9FV9o/xeao/wuV/sqq1adDFuQ4QSENBRCRknA7K9Gl7nxfki/AuaVy/ZNqW0BvQ2itqdw1GxJs2ortEjv6URAaDMWJKf5lvmngOdU6jeQolSilRChuis/s91bqu6WXXur9Ta+72WKxXi9wmI6bbHLLUdh11Dbjx3OcWW8DCUKTvBACt4qNTMQ6BqlKlMwozsiQ6iPHZQXHHXVBKEJAyVEngABxya5d0Zt01fpbUV7RqN6936xJ0jN1PDevtpi22Q4YykZS0lhRPNrS51OpC0kDrzUttlu15dtktw1VqTW6LixddNyJj1hjWthqKwXYxWhLToHOnc3sZWpW9jqFM1wbzgT411gxpsKS1MhyW0vMSI6wtt1tQBStKhwUkgggjgQar1ys3tJ1TbNDbFdE6SZuaJlx0hHucuXZ4sSRLQy0xHQlDSZbiGuKnMqUd4gAYTxJG5diN11tc9N3BGubdJhzo09bUORMbjtPy4u6hSHHW2HHG0L3itJCVYO4DgZxRSuCZ6SP/ADxqYdnc0I/b796hU0qFaS/p1qf9Fg/vfqa1qxXvfCP+qKxSlK5CClKUBHdo/wAXmqP8Llf7KqoQ/wCaMf8Agn91Zy9WxF7s0+3OqKG5cdyOpQGcBaSknH21CW75KsrDcS62q5d1MpDanYMF2U07jhvpU2k4BxnBAIzivRoevTyR3TMt0QW08mnTNou9teRc75Islrnm527TL8xKrZDk7ylJW22EBfgqUpSUqWUpJ4AVIYOxvT0XROp9KPCTPs+opU6XNbkuDe3pbilupQpKU7oBWd3tGBxJ41mumcbyZfv2JL9lTpnG8mX79iS/ZVu6CfZYyvgQuxcnq0WnUcK+TtQ6i1JPjQH7Uo3qW282/DdCQphaEtpTugpCspAUSPCUocKqaP2BWzRm5Ej6l1PP0+1HdiR9PT7iHYLDK0lHNgbgWpKUnCQtat3hjqrPXTaxp+yTbdDuJuUCXcnSxCjybXJbclOAZKG0lsFagOOBk1kumcbyZfv2JL9lToJ9ljK+BBDya7AjTOmbVGvmooUzTJcTZ72xOSJ8NlYCTHSsoKVNboSndWlXBKfFmtg6Q0yNI2Jm2d9Llei2pa1TbvI5+Q4pSio7ysAYycAAAAYAAAqj0zjeTL9+xJfsq+jWLCzhFqvqldgNllJz9pbA+806Ca/xJlZeaS/p1qf9Fg/vfqa1GdH2mUzKuV2msmK/cC2lEZRBW002CE75HDeJUokAnGQMnFSauLEyUqmnBckkGKUpXKQUpSgFKUoBSlKA535S3x0cnv6yyPwxroiud+Ut8dHJ7+ssj8Ma6IoBSlKAUpSgFKUoBSlKAUpSgFKUoDnflLfHRye/rLI/DGuiK535S3x0cnv6yyPwxroigFKUoBSlKAUpSgFKUoBSleFuobxvrSnPVvHFAe6tLu/Mi2qa9b4qJ09tha48Vx7mUvOBJKUFe6rcBOBvYOM5weqq3dTP9s3/AKhTupn+2b/1CrZg/LXav/KFP601/oS6ytnC7PJ0XdnZjsF28Fan1FBbLRJjpLZB7cK8WK7x5L23qTyjtmzurn9ML0q13e7DYjqmd1B9CEoJdSvm2+G8paMYPFs8ewcM8ubktT3+UdYpmk46VxdoEoN+APe487IDylkDwUqSQ6Sf+6epNfo3s20XZtl2g7FpSzqbRb7TFRGbOQCsgeE4rHylKKlHzqNLMEppVLupn+2b/wBQr6JDSiAHUEnqAUKWYKlKUqAUpSgFWt0ukWy26ROnPJjxGEFbjiuoAeYcSfEBxJ4CrqtQbdLyt2fZrGhWGClc+Qn8opIS0POMlavpQmuzB4frVeNLjv3FRHNVbRbzqx9xLUh+z2rJDcWMvm3XE9inHE+ECfyUkAZwd7GahirDbXFKW5AjurVxUt1oLUr6SeJq/pX0ejShh45KSsjHMzH9HrV5Mh+jo9VOj1q8mQ/R0eqshUQvO1zSWn7y5a594QxKaUlDx5lxTTClY3UuupSUNk5HBSh1itkqqgrylbxF3xM/0etXkyH6Oj1U6PWryZD9HR6qjt82w6R05c51vuF2LMuApAloRFecEcKQlaVOKSghKClafDJCesZyCBd6o2maa0c/DZut0Sy/LQXWWmWnH1qbHW5utpUQj+8cDz1j08Ff19t9Rd8TL9HrV5Mh+jo9VDp21EEd7IeDw/m6PVWC2T6ul682d2S/zm2GpU5kuOIjJKWwd5Q8EEk9QHWTUtrKFTPFST0Yu+JXslyuGl3Ers09+3hJHvCVFbCh4i0fB+0AHxEVvLZ/r5nWcNbbyExbtGA7ojA5SQepxBPWk4+kHgewnQ1XdivDmm9S2i6tq3Q1IQw9x+Ew4pKHAfHjIVjxoFeVj8DDFU3JL11s/wAMqd9GdN0pSvnoFaQ23RVR9a2qUr/pyoC2UnHym3N4jP0Oj7j4q3fUZ2g6NTrWwmKhaWZzCw/EeXnCHACMKx8lQJSfMc9YFel/T8RHDYmM57bPxKjn+lfJUZxiRJt8+MqPLay2/FeHEfxJPYRwI6qhvuL6B+Zlj/Z7X8NfQm5NJws/H+GYEzrnKJotm3XTVFh1PY9Z3Lvpd5L7Ttnly+98uNIXkFwNuJbQQFELCwOCe2tte4voH5mWL9ntfw1MkpCEhKQEpAwAOwVonRda2dJW8fNIGm3tLzWPdrjtW2UWJkFlmCCytXdITbUt4bJHvh3hu8M8eHXVhpNVz2easZudz07ebpHu2nbZFZfgQlPuRHWEKDjDiRxb3isKycDIOTw4b0pU6srqSdmrv7tv8ggGwS2zLRsg0zDnxH4ExqOoORpLZbcbPOKOFJPEHjU/qO37Z1pbVE7u28adtl0l7gb5+XFQ4vdHUMkZxxNY73FtA/Myxfs9r+GtkIzpxUIpNLTf+ATOqb0VVxdhwW+LsuUzHQAM8VOJGfsGT9ANY2xaZsmjYTzNotsKzRFr51xEVpLKCrAG8QABnAAz5q27sl0I+9PY1JcWVMtNJV3BHdSQslQ3S8oHq8HISPEpRPWK1YnExwtF1J7/AA7yx3ubfpSlfNCilKUBhdSaMs2rmkIusFEhbYIbfSSh1vPXuuJIUn7DxqFPbA7WpRLN9vUdJ6kBbCwPoKmifvJrZ9K7KWMxFBZac2lwLc1Z7gMH5y3v7ovsKe4DB+ct7+6L7Ctp0rf6TxfzPL9C5qz3AYPzlvf3RfYU9wGD85b390X2FbTpT0ni/meX6FzVnuAwfnLe/ui+wr6NgMDPHUl7I83co/8AhW0qU9J4v5nkLkKsGyDTlhkNyVMPXSW2QpD9xc53dI6ilGAgHzhIPnqa0pXFVrVKzzVJNv6i9xSlK0kP/9k=", "text/plain": [ "" ] @@ -44,42 +69,90 @@ "output_type": "display_data" } ], - "source": ["from IPython.display import Image, display\n\ntry:\n display(Image(runnable.get_graph(xray=True).draw_mermaid_png()))\nexcept Exception:\n # This requires some extra dependencies and is optional\n pass"] + "source": [ + "from IPython.display import Image, display\n", + "\n", + "try:\n", + " display(Image(runnable.get_graph(xray=True).draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[HumanMessage(content='What is 1 + 1?', id='bb29f237-0e4d-4354-92e1-d46434c67fe7'),\n", - " AIMessage(content='1 + 1 equals 2.', response_metadata={'token_usage': {'completion_tokens': 8, 'prompt_tokens': 15, 'total_tokens': 23}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-2ff0112a-9402-44a1-a992-c44fb49fa894-0', usage_metadata={'input_tokens': 15, 'output_tokens': 8, 'total_tokens': 23})]" + "[HumanMessage(content='What is 1 + 1?', id='28f82989-8a35-4c1e-b12d-aa1b54c2b5ea'),\n", + " AIMessage(content='1 + 1 equals 2.', response_metadata={'token_usage': {'completion_tokens': 8, 'prompt_tokens': 15, 'total_tokens': 23}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-aebd1367-b64d-4c25-971e-db7c88d55aac-0', usage_metadata={'input_tokens': 15, 'output_tokens': 8, 'total_tokens': 23})]" ] }, - "execution_count": 17, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], - "source": ["runnable.invoke(HumanMessage(\"What is 1 + 1?\"))"] + "source": [ + "runnable.invoke(HumanMessage(\"What is 1 + 1?\"))" + ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 5, "metadata": {}, "outputs": [], - "source": ["from typing import Literal\n\nfrom langchain_core.tools import tool\n\nfrom langgraph.graph import END, START\nfrom langgraph.prebuilt import ToolNode\n\n\n@tool\ndef multiply(first_number: int, second_number: int):\n \"\"\"Multiplies two numbers together.\"\"\"\n return first_number * second_number\n\n\nmodel = ChatOpenAI(temperature=0)\nmodel_with_tools = model.bind_tools(tools=[multiply])\n\ngraph = MessageGraph()\n\ngraph.add_node(\"oracle\", model_with_tools)\n\ntool_node = ToolNode([multiply])\ngraph.add_node(\"multiply\", tool_node)\ngraph.add_edge(START, \"oracle\")\ngraph.add_edge(\"multiply\", END)\n\n\ndef router(state: list[BaseMessage]) -> Literal[\"multiply\", \"__end__\"]:\n tool_calls = state[-1].additional_kwargs.get(\"tool_calls\", [])\n if len(tool_calls):\n return \"multiply\"\n else:\n return END\n\n\ngraph.add_conditional_edges(\"oracle\", router)\nrunnable = graph.compile()"] + "source": [ + "from typing import Literal\n", + "\n", + "from langchain_core.tools import tool\n", + "\n", + "from langgraph.graph import END, START\n", + "from langgraph.prebuilt import ToolNode\n", + "\n", + "\n", + "@tool\n", + "def multiply(first_number: int, second_number: int):\n", + " \"\"\"Multiplies two numbers together.\"\"\"\n", + " return first_number * second_number\n", + "\n", + "\n", + "model = ChatOpenAI(temperature=0)\n", + "model_with_tools = model.bind_tools(tools=[multiply])\n", + "\n", + "graph = MessageGraph()\n", + "\n", + "graph.add_node(\"oracle\", model_with_tools)\n", + "\n", + "tool_node = ToolNode([multiply])\n", + "graph.add_node(\"multiply\", tool_node)\n", + "graph.add_edge(START, \"oracle\")\n", + "graph.add_edge(\"multiply\", END)\n", + "\n", + "\n", + "def router(state: list[BaseMessage]) -> Literal[\"multiply\", \"__end__\"]:\n", + " tool_calls = state[-1].additional_kwargs.get(\"tool_calls\", [])\n", + " if len(tool_calls):\n", + " return \"multiply\"\n", + " else:\n", + " return END\n", + "\n", + "\n", + "graph.add_conditional_edges(\"oracle\", router)\n", + "runnable = graph.compile()" + ] }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { - "image/jpeg": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAEuAIkDASIAAhEBAxEB/8QAHQABAAIDAQEBAQAAAAAAAAAAAAYHBAUIAwkBAv/EAFEQAAEDBAADAgkGCAgNBQAAAAECAwQABQYRBxIhEzEIFBUWIkFVlNEXN1FhdZMkMkJWcYGy0gkjNVJzkbG0MzY4Q1NUYnKWo7PB1DSEkpWi/8QAGwEBAAIDAQEAAAAAAAAAAAAAAAEDAgQFBgf/xAAzEQACAQICBwUIAgMAAAAAAAAAAQIDEQSREhMVITFBURRSYaHRBUJiY3Gx4fAiMzKywf/aAAwDAQACEQMRAD8A+qdKUoBSlKAV4ypjEFhT0l9uOynW3HVhKR9HU17VCuLrSHsTYbcQlxtVzghSVDYI8Zb6EVnBJySZnCOlJR6kg86rJ7Yge9I+NPOqye2IHvSPjVeeb9r9mw/uE/Cnm/a/ZsP7hPwrjbVw/clmjrbO+LyLD86rJ7Yge9I+NPOqye2IHvSPjVeeb9r9mw/uE/Cnm/a/ZsP7hPwptXD9yWaGzvi8iw/Oqye2IHvSPjTzqsntiB70j41Xnm/a/ZsP7hPwp5v2v2bD+4T8KbVw/clmhs74vIsPzqsntiB70j4086rJ7Yge9I+NV55v2v2bD+4T8Keb9r9mw/uE/Cm1cP3JZobO+LyLD86rJ7Yge9I+NPOqye2IHvSPjVeeb9r9mw/uE/Cnm/a/ZsP7hPwptXD9yWaGzvi8i1WXm5DSHWlpdaWApK0HaVD1EEd9f3UV4VgDhrjAHQeTmP2BUqrtVI6E5Q6M47VnYUpSqyBSlKAVDOLH+K8b7Ug/3lupnUM4sf4rxvtSD/eW6zh/ki2l/ZH6o1VKwL3frZjNseuV4uMS1W9nl7SXOfSy0jZCRzLUQBskAbPeRUXTxy4cLOk8QMWJ0ToXqN3Dv/Lr58oylvSPWOSXFkpvl5iY5Zbhdp7vYwYEdyVId0TyNoSVKOh1OgDVP5l4Qs1jgtk2ZWLEr5Cfgwm5UI3qI2hp5DoJQ8OV70kADahsKAKfR61MpPF/Br5FkW+05TjGQ3OS0tqNaUXiMozHCk8rOuY/jH0e49/caqC38HswvOB8S8djWV3DMeu9pTHs+O3C6omojzNLLimlIUoNMq/iwEb6HZCU91bNKEVvqK29cehRUlJ7oeJcMridLg4nBvDuEZS7KkvdgLRHjMOy0aBPOvleLaUEJ6Er9YHedVppfhF41Fxax3sQLy+Ltd1WJFvahjxxiakOczLrZUOVQU0pPQnqUnu2RGM6tOa8Q7ViEq64JKXbYMt0XjERd4wVOHYpDLpWHA2ttDnMezUob6Eg61Ufwvg9lVmj45GcxiPaI8DiA9fzGhzGnGI8F2K8E8h2knkW4lspCQdjYBT1rNU6ejeT3/X6mLnO9lw+hM5XHC/N8WsfxxOD3xEC4Wd6c8wtEXxppwSGmwtR8Z5A2hKiVa2r00a31Aueqp4gWXJrTxbxzM7DYDk0Zm1S7RLhszGo7rXaOsuodBdUlKk7aIIB2NggGpC9xu4dRnnGXs+xhp1tRQttd5jBSSDoggr6GqZx0lFwWRZGWi2pMmtKg5458NwfnBxX/wC6jfv1Mocxi4RGJUV9uTFfQl1p9lYWhxChtKkqHQggggiqXGUeKLVJPgyS8LPm2xj7OY/YFSmotws+bbGPs5j9gVKa+i4j+6f1f3PIS4sUpSqDEUpSgFQzix/ivG+1IP8AeW6mdarJcci5ValQJinm2i426FsL5FpUhYWkg/pArODSkrmcJaMlJ8iFONodSUrSFpPeFDYrx8Qi/wCrM/8AwFbr5KYPti9++n4U+SmD7Yvfvp+Fee2R81ZM7nb6XRmnTCjoUFJYaSodQQgAivatl8lMH2xe/fT8KfJTB9sXv30/Cmx/mrJjaFLozW0qFeEXYXuGnBDMcosl7uzd1tcFT8dT0rnQFhQHVOuvfUww/h3HvWJWS4SbzeTJlwWH3SmZoFam0qOhrp1Jpsf5qyZO0KXRnrXgYEYnZjtE/wC4K3PyUwfbF799Pwp8lMH2xe/fT8KbI+asmRtCl0ZpRb4oH/pmfuxXulISAAAAOgA9VbP5KYPti9++n4U+SmD7Yvfvp+FNkfNWTHb6XRmbws+bbGPs5j9gVKawbHZ4+P2eFbInMIsRlLDXOrmVypGhs+s1nV6OrJTqSkuDbOC3d3FKUqogUpSgFKUoBSlKAUpSgKY8Mr/Je4j/AGWr9pNWLw5+b3F/suL/ANJNV14ZX+S9xH+y1ftJqxeHPze4v9lxf+kmgJFSlKAUpSgFKUoBSlKAUpSgFKUoBSlKAUpSgKY8Mr/Je4j/AGWr9pNWLw5+b3F/suL/ANJNUZ4YfGHApXAPiRjrOb449kAhORTaW7tHVLDyVgKb7IL5ucEEFOtjRq0ODHEjEstw/HrfY8pst6uDNqZLsW33Bl91vs22kucyEKJHKXEA7HQrSD3igLFpSlAKUpQClKUApSlAKUpQClKUApStHkmYQMZ7Jt8PSprwJZhREc7zgHedbASn/aUUp3ob2RWUYubtElJydkbylVy5nmSyTzMWi2wm/UJMtbrn6wlAAP1BR/TXn55Zd/q9l/req3VdZLP0NpYWs/dPn1/CZ8CxhHEuJn1rjdnZ8m2mXyD0W56B6R+rtEAK+tSXDVl/wV3BmTb4eQ8T5yXGUTmlWa2pPQOtBaFvufWOdttIP0ocFdBcZ8QncdsAm4jksa2Jt8lbbofhrWh9hxCgUrbUpKgFd46g7ClD11usEReeHGG2bGLJCszNqtUZEVhK1OlRSkfjKI1tSjtROupJNNUu8syeyVuhdFKrXzyy7/V7L/W9XszxAyCIsGZY4k1nfU2+WUugfUhxISf1rHwaq/CSz9SHhay90sSlavH8lgZNFW9BdUVNnkdYdQW3WVfzVoPUH6PUR1BI61tKqlFxdpLearTTsxSlKxIFKUoBSlKAUpSgNVlN+TjVhl3BTfbLbAS0zvXauqUEto36uZakjf11XsOM42XZEp0yZ8g88h9X5SvoT9CBshKfUPrJJ3vFVauyxpr/ADTt3SHPo0mO+tP/AO0IrV1bP+FOKXvb/wDiXkzs4GC0XPmKVz/fWbnn+bcVPG8uvONsYk0w1bGLVOVFbaKogkGS8kdHdqUQAvaeVBGt7Nafh5cb3xkz6yPXq/3u2Qn8EtV2kW61z3Ybbktx5/bv8WQUghPcCOYcoOwkCtU3tbvtY6YpXKWRZdfhmMHMsbl5CMeVl7Fmefud9JiyUqlCO82zACCkNg8wSsqSsFO9Gp1hFqn8XMozS8XXK7/bTZcikWmFa7RcFRWI7Mfl5VONp6Oqc2Vkr2NKAAFLEKrd2SLesGVWvJ3Lqi2SvGVWuau3TB2a0dlIQlKlI9IDegtJ2Njr399bWuWbpmV2wnAOLcuyOpiz5PEI2/xxbgbEVDwhtqcKylQRoKICilXKSDo60bD4TYfxAxjMn13iQpOMOwVJXEm5I9eXxKC0lDiFux21ISU9oFJ2Rvl0BqgjVu7WLYlLk2x9N2tyVKuEZO+xSrlElsdS0r1HfXlJ/FVo/SDZ9suMe8W2JPiOB2LKaQ+0sflIUAUn+oiq7rfcJlqVg0VJ/FakzGW/6NEp1KP1cqU1tL+VK75NLO/oaGOglaaJhSlKqOQKUpQClKUApSlAR3PrG/fcbdRETzz4ziJcZO9c7jagrk36ucBSN/Qo1DYUxufFbkMklCx3HoUnuKSPUQdgj1EEValQ3JMHeeluXGxvMRZjh5n4r6SGJJ/nEpG0L/2wFbHek9CLVapHQbs1w9DoYXEKk9GXBlW5jwVwzP7r5SvtlTMmlpLDriJDzIfaBJCHUtrSHUgk+isKHWt7Cw2zW7InL7FgoYubkFq2l1tSgkRm1KU22Eb5QAVq6gb663oCsDOc7b4W2F685hbpFktjK0trmlxp1kqUdJCSlfMST3ApB+rVbVi7zZLDbzeOXtTbiQtJMTWwRsdCajs9XkvNHVVSjxTRDrh4PnD+6XGZOk4+FyJUnx1fLLfShEjmCy82gOBLThUNlaAlR67PU7ysg4HYRlGRu364WMLuj3J27rEp5hMjk/ELqG1pS7rQA5weg1Uq8oXD82737qP3qeULh+bd791H71Oz1eg06HVeRpJHCnEpczIpL9jjvLyJtLV1Q4VKalhI0kqbJ5ebX5QAV0HXoK/MG4VYxw4XKXj9vciOSUIbdcflvyVlCN8iAp1aiEjmVpI0Bs1mZDmHmrZJ14u1lvEK2QWlPyZK4m0tNpG1KOiToDqa98Yvc7O7NBuuN2hydbJzYej3GRIaajLQe5WwpS/6kHuO9U7PU5rzXqNZRW+6M65zHIkb8Ha8YmOqDUaPvRddP4qf0esn1AE9wqxcXsicbx632xLheMZlLa3T3uL16Sz9albP661eLYZ5If8AH7i+i4XUpKUuJb5G46T3paSSSN+tRJKvqGkiUVLajHQi79f3wOPiq6rO0eCFKUqo0hSlKAUpSgFKUoBWqynKrPhFgmXu/XGPabTDRzvzJSwhtsbAGyfWSQAO8kgCs24XGJaYT0ydJZhRGU87siQ4G220/SpR6Afpquzj974kZLldnz7ErDIwOJJirsaXV+MvS3EacU84k+ikBRSkJ0D6KweZJ2QM6wWPJr1luTTskulmvOES/FVWC0x4oc7NKAHC+64oektSyCNbACEKGjU+r8A0NDur9oBSlKA/l1pDza23EJcbWClSFDYUD3giq5uqr3w9y+RfZl/stq4SQLHyPW56OGHLe80r0VtrSNFtSDylJ7uRISOpqyKx7hb4t2gSYM2O1LhyW1MvR30BbbqFDSkqSehBBIINAfluuMW72+NOgyWpkKS2l5iQwsLbdQobSpKh0IIIIIrJqtbW3feHmYxLFAsNitXCCBYytE9uR2DsB9pXVC0H0S2UHfN01yrKlb0DY7TqHm0ONrS42sBSVpOwoHuINAf3SlKAUpSgFKUoBSlKAqLIbB8vs7L8KzXCJkLCbZLhmJPfmls3Z1BDqylts/4EHkGyo72egUk8tssMNRWG2WW0MstpCENtpCUpSBoAAdwA9VQPgdaptmwRMa4ZojPpHjslflht3tAUl1RDW+ZX+DHo636u4VYFAKUpQClKUApSlAazJsatmZY9cbHeYbdwtVwYXGkxnR6Ljahoj6R9RHUHqOtQXDRO4b5VYuG1rxCccGh2MGFknjnbpadbVylh4L9JJKSkpIJ31AACTy2bVf8AE+1TblfsEciZojFG4t5Q8/DW7yG8I5D+CgcyeYnv1pXd3UBYFKUoBSlKAUrzfktRWy486hlsd63FBI/rNa45ZYwf5Zt/vSPjWahKXBA2tck+G54T/Ezwabvj0rGrJj9wxa6MKaVKucaQ663MQolSCW3kJCS2UFII2Slzr06dQedlj9s2/wB6R8aq7wmMFxvjzwbv2KKu9rFxW34zbHnJTemZbYJbO99AeqCf5q1VOrn3WTZnE3gk+GZnTmb4pw0sGHY2zZ7reAp9MVqWp5ltx4OSnQpySr8VvtVDfQaB6gaP1Ar5/fwbXBNrh+rIM6zFLdlvjpNrt0K5KDLrTI0p13kUdjnISlJ0OiV94VXdfnZY/bNv96R8aaufdYszbUrU+dlj9s2/3pHxrLh3aDcSREmR5RA2exdSv+w1DhJK7QszLpSlYEClKUAqqeNUrCY+UcMU5ZDmyri7kTaLCuISEtTeRXKpzShtOt94P6KtaoPxFuuUW69YY3j2PRb3Ck3ZLV2kSNc0CLynb6NqHpA6Hr7+6gJxSlKAVEcuy5+JL8k2nkNwKQt+S4OZuIg93T8pxX5Ke4AFSvyUrlUh9EWO684dNtpK1H6gNmqhxpbku1N3F/Rl3I+OvqGztSwCB19SU8qR9SRVsbRi6j5bl9fx6cjdwtFVZ/y4ILxqDKe8YuDZu8sjRk3DTyz130BGkj6kgD6q9vINs9nRPuE/CtBn/EmBw7NkRMg3G4v3iYYESPbWUuuLe7JbgBBUnQIbI33AkEkJ2oQtPhO2NEa4SZWNZRAiWqWmFeJEmC2EWtwlIHbEOnmGlpVtrnASoE62KrdWpLjJnb0oQ3cC0/INs9nRPuE/CnkG2ezon3CfhUdPFC1B/OWvF5nNiCUqnnkRp0GMmSOy9Lr6CgPS5ev1daiuO8UZ+QcS5wiJlS8fXhsG/Q7UlpoSFOvOv9ASR6akoQnlK+UEd46msdZPqyXOK3FmeQbZ7OifcJ+FPINs9nRPuE/CqCgeENfLt4Ns3NLlZb1YpyIwWbrboMSQ2nmWsdsyy5I9JCOUAhwpO1DQPUiw7/xsjWDJ7tjzGM5HkM+0RI8yY5aYrLiEtuhfKoczqST/ABavRA2fyQrR01k+rIVSL3k68g2z2dE+4T8K8JGKWaUQpy1xCtPVLiWUpWk/SlQGwfrBqt7ZxcXkXE62Kskty7YnNw1++sRIzKO0feTJbSkpKgFBfKpSeQqA2euiNiS2/jBYbra8HnQ0ypKcwWEW9ptCe0QOxW8tTo5vRCEoIVokhWho1Kq1Iu6k8yVKLJ1Z8km4k4lMyTIuVkJ0pT57R6GP53P+Mtsevm2od+yBoWUhaXEJUlQUlQ2FA7BFVmpIUCCAQehB9dbzhZMK7BJtqjvyRLXBR39G+VLjSev0NuIT+qrr62Lk+K811/eNzlYyhGFpxJlSlKpOWKr/AIn2qbcr9gjkTNEYo3FvKHn4a3eQ3hHIfwUDmTzE9+tK7u6rAqqeNUrCY+UcMU5ZDmyri7kTaLCuISEtTeRXKpzShtOt94P6KAtalKUBjXGIJ9vlRSdB9pTZP0bBH/eqlxVxS8ctocSpDrbCWXUKGilaByrB/QpJFXHVdZXYXcduMm6xGFPWqWvtZjbQJXGd0AXQn1tq16WuqVelohSim6K04OmuPFen70sdDB1VTm1LmVrxOxW6ZDlXDiZb4vjEa0X1UyavtEJ7JnxR9vm0ogq9JaRpOz17tbqvsw4V5RdOHnHO2RbX2s7JbmqRamvGGh4y2Y0ZG9lWkek2saWQen1ir+jSmZsdt+O82+w4OZDjSgpKh9II6GvStXenZnZdNSv4+lih8uxTNLTf+K7dkxgX6LmcRsxJiZ7LCIrqYQjKQ8lagr8gKBQFA82iU99ZvDrBMnxDP8XuEi1Ictr2GQLHPeTKb54EqN2iyFJ36aVFzlBQToj6OtXXSoI1ave5zUzw8zk+DJknDJ3Fim5Q7e7CgTUT46mbkS8opKAVgt+iQT2gT1rZP5Dk2PeEBxIONYkvKpLtos6SkT2YqWFgSuQrLhG0nat8uyOXuO66DrEYtEGNcpVwZhR2p8pCG5EpDSUuvJRvkStYG1BPMrQJ6cx130uY6q1rPh+fUozhlwgyXhXk/D54RWLxEj49Js12fYfS34o87JTKLiUr0Vt8wUgAde46FYPAvFhI4y5lIizY9yxHF5D8awKjLC2mnZxbky0JIJG21aR07gsiujO+sOz2S3Y7bmoFqgRbZBaGm4sNlLTSP0JSAB+qhKpJNW4IzK2/CuOow77OIPZzbmtTexr0W222D+rmaWf11HowfySa5bbQ4lTiFckuYOqIg9Y33FzXcj9BOh32babXGslsiwIbfZRYzaWm0b3pIGhs+s/X6624p04O/GX24+ljn42qmlTRl0pSqTkCoPxFuuUW69YY3j2PRb3Ck3ZLV2kSNc0CLynb6NqHpA6Hr7+6pxVf8T7VNuV+wRyJmiMUbi3lDz8NbvIbwjkP4KBzJ5ie/Wld3dQFgUpSgFKUoCMXThxYbpJck+LOwZTh2t6BIcjlZ3slQQQFHfrINa/5J7d7WvQ/98fhU3pV6r1Fu0ixVZx3KTIR8k9v9rXv34/CnyT2/wBrXv34/CpvSp19TqZa6p3mc1eCxCl8WeFCb/kF6ujtxNznReZiT2aeRp9SEdAO/QHWre+Se3+1r378fhVW+Ab8wLf23dP705XRNNfU6jXVO8yEfJPb/a179+Pwr0b4T2MqBlOXK4JH+bk3B0tn9KEqCVfoIIqZ0qNfV5SIdWo/eZ4QYMa2RGosOO1EjNDlbZYQEIQPoCR0Fe9KVS227sqFKUqAKqnjVKwmPlHDFOWQ5sq4u5E2iwriEhLU3kVyqc0obTrfeD+irWqD8RbrlFuvWGN49j0W9wpN2S1dpEjXNAi8p2+jah6QOh6+/uoCcUpSgFKUoBSlKAUpSgOdvAN+YFv7bun96cromuWuBd8f8GzOn+DeYJQxbbvOkXDE8jAKWLiHXCtcVzZ0h9KldB3K2AO9PN1LQClKUApSlAKUpQCq/wCJ9qm3K/YI5EzRGKNxbyh5+Gt3kN4RyH8FA5k8xPfrSu7uqwKqeexbeLfFddqu+H3hqPgkuNcrdkEhSmI0iapBJS0nYLoShQ2rqnZIOtJ5gLYpSlAKUpQClKUApSlAQvi7wlsHGrCJmM5CwpcZ3TjEpk8r8N9P4jzSvyVpP9YJB2CRVYcC+Ld+x7LHOD3FF9PnvBaLlpvRHK1kUJO+V1BP+eSAedHf0J66VXQdcg/wi/EbC8S4e2+DdIdycz0OpnYxNgNOMKhPJV6T4l8nJpHKnnaSSs8zWwkKS4kDq3zitPnB5B8pw/Lnivj3kzxhHjPi/Pydt2W+bs+f0ebWt9N7rY18cvBM463pHhhWDK8ou79ynZJLVbbjLkEbd7dPZtjp0ShK+x0kAJSlAAAAGvsbQClKUArXQsjtNyu9ytMS6QpV0tnZePQWJCFvxO0SVN9qgHmRzpBKeYDYGxus9a0tIUtaghCRsqUdAD6TXw9yzwisrb495hxFxK+zLJPu0qUhp+OQFGIs8qGlAghQCEt62OikJUNKSCAPrHl+Q2/jnNznhXYr/f8AGLvaERPKV7tkYthsOnnUw28ofjlsdSNdFggq0oVbcKKIMNiMlx11LLaWw48srWrQ1tSj1JPrJ76qnwaOPth8ITh8xfLQzPjzmEts3VqbFUns5QTpSQ+G0tPHSQrbfUJW3zJbKgkW5QClKUApSlAKUpQClKUBW7+dZJIu11ZgxrWmNDlrjJMguFauUDqddPXWuyCXecstT9svdmxm7218adiTmHHmlj60q2DX5bP5XyT7Ve/sTWyrUxOMnRrSpxSsvA8jivaWIpV5wi9yfRHKWaeAZjd+vDN3x0N4RcGVh5vyTIdWyHAoKC+R0KIII6BKkgfRXVgy7LwP8DZD998a/aVq7Qq9Fkau1sV1WSHndl/+hsn/ADvjTzuy/wD0Nk/53xpSm0KvRZDa2K6rJGnzGdmGW4je7GH7VbTc4L8LxyP2vaMdo2pHOnfTmTzbH1iueOGHgNYhw8kNzJ9ot2Y3BtXOly+uurZSdd3YoCUKH1LC66JtmVWu83y82eHK7a42dbSJzPZrT2KnWw42OYgBW0kH0SdevRrbVO0Ky5LIl+1cWuL8kY8C/ZLaobUSFAx6HFaHK2ww26hCB9ASOgFbGw5pfn8pt9suce3dhLQ6oLiFzmSUJB/K6aO6xqxrf84OOf0cr9hNbWFxc69XVzStZ8uibNzBe0cRXxEac3ud+S6Fo0pSto9WKUpQClKUApSlAVLbP5XyT7Ve/sTWyrW2z+V8k+1Xv7E1H73b+IL10kLtF+xqJbSR2LM2ySH3kjQ3zLTLQFdd9yR/3rjY1XxMz5/jEniql3beyZVz5xXueW5bxsRhVlU8i3wrE3djHjZA7ZnJDi31tlfatMOLWlAQkcg5RtezzdALENr4perJcQ/4dlf+dX5duElvz+Db3M9jQbxeoKl9hcLOmRbVNoV3pSpL6nACNbHPo/RWrFqLuyqk4UpaUnfP8fcq1NozhWVcLcSy7JrhGcls3zxxVlui0rlMNmOqMl15KGypxKVAFxKUKPpaI5lb1VvyzI7k3ZeH68mucSLKza62J2/+Mfh/icVtbzbPbHqHF+ijtPxtJPrNdA2/htjdqk46/EtiWHMfYejWwodc1HbdCQ4Nc2lc3InqrZ6dO81gXXgzhl7slxtE6yIkQLhcl3h9Cn3QvxxR2XkLCuZtX+4U62da2az1kea/d5csRDmvJcbvf5rIg/AvH04vxW4vW1E+4XJDMu2csi6SVSJBBhJVpTiuqtb0CdnQHU1dtV5B4UpwJiYrh55OsU64vNu3B+8NyriJHIjkSeslCgrWvS5jv1gnrX9+S+Kf5zYf/wAOyv8AzqwlaTvcoqONWWkpdOPgkvEsCsa3/ODjn9HK/YTUexmFm0e4qVkN5sE+B2ZAatlpfiu8+xo865Lg1rfTl+jr9Mht/wA4OOf0cr9hNbmA3YhfSX+rNv2crYuFnfj9mWjSlK657sUpSgFKUoBSlKAqFSLlab3fkqsN0kIeuDj7bsdgLQtCgnRB39VenlKf+bd791H71W1SsalKhVk5zhvficqr7Mw9WbqSvd+JUvlKf+bd791H71PKU/8ANu9+6j96rapVfZsN3HmVbIwvjn+CpfKU/wDNu9+6j96nlKf+bd791H71W1SnZsN3HmNkYXxz/BUvlKf+bd791H71PKU/82737qP3qtqlOzYbuPMbIwvjn+CpfKU/82737qP3q98eauNwzizSVWa4wo0ZuR2j0tkISOZIAHeatOlW06dGlLThHfv59VYuo+zqFCoqkL3XiKUpUnUP/9k=", + "image/jpeg": "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", "text/plain": [ "" ] @@ -88,53 +161,63 @@ "output_type": "display_data" } ], - "source": ["try:\n display(Image(runnable.get_graph(xray=True).draw_mermaid_png()))\nexcept Exception:\n # This requires some extra dependencies and is optional\n pass"] + "source": [ + "try:\n", + " display(Image(runnable.get_graph(xray=True).draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[HumanMessage(content='What is 123 * 456?', id='fa2dbb36-c61b-4ce1-892d-c08f3e741035'),\n", - " AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_ZpoO6ClLFKkppN9Y8GEelZH1', 'function': {'arguments': '{\"first_number\":123,\"second_number\":456}', 'name': 'multiply'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 19, 'prompt_tokens': 57, 'total_tokens': 76}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-ee9faec5-3526-45d5-89b1-5292755a6893-0', tool_calls=[{'name': 'multiply', 'args': {'first_number': 123, 'second_number': 456}, 'id': 'call_ZpoO6ClLFKkppN9Y8GEelZH1'}], usage_metadata={'input_tokens': 57, 'output_tokens': 19, 'total_tokens': 76}),\n", - " ToolMessage(content='56088', name='multiply', id='b9992170-ca76-4256-8560-29b329c1b56e', tool_call_id='call_ZpoO6ClLFKkppN9Y8GEelZH1')]" + "[HumanMessage(content='What is 123 * 456?', id='81692a54-acd4-4ef7-9ccf-49b2efc0b9b1'),\n", + " AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_XneG8vpjfal3lKO4q4fmfUYc', 'function': {'arguments': '{\"first_number\": 123, \"second_number\": 456}', 'name': 'multiply'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 34, 'prompt_tokens': 57, 'total_tokens': 91}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-2fe18a05-45bf-4cf6-ba29-c5956fc928bf-0', tool_calls=[{'name': 'multiply', 'args': {'first_number': 123, 'second_number': 456}, 'id': 'call_XneG8vpjfal3lKO4q4fmfUYc', 'type': 'tool_call'}], usage_metadata={'input_tokens': 57, 'output_tokens': 34, 'total_tokens': 91}),\n", + " ToolMessage(content='56088', name='multiply', id='d494f0c9-daa8-4da2-aa4b-e9d4ace87912', tool_call_id='call_XneG8vpjfal3lKO4q4fmfUYc')]" ] }, - "execution_count": 20, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], - "source": ["runnable.invoke(HumanMessage(\"What is 123 * 456?\"))"] + "source": [ + "runnable.invoke(HumanMessage(\"What is 123 * 456?\"))" + ] }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "[HumanMessage(content='What is your name?', id='09f03ac4-ca68-4464-9ec3-2c393699b3bb'),\n", - " AIMessage(content='My name is Assistant. How can I assist you today?', response_metadata={'token_usage': {'completion_tokens': 13, 'prompt_tokens': 54, 'total_tokens': 67}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-a21b2f58-3fa6-428f-99e5-9c4e071a9319-0', usage_metadata={'input_tokens': 54, 'output_tokens': 13, 'total_tokens': 67})]" + "[HumanMessage(content='What is your name?', id='184ed583-58f1-4d4c-a428-d573b5a56286'),\n", + " AIMessage(content='My name is Assistant. How can I assist you today?', response_metadata={'token_usage': {'completion_tokens': 13, 'prompt_tokens': 54, 'total_tokens': 67}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-38dcd15d-8bdf-491a-b7ca-771bb64bb824-0', usage_metadata={'input_tokens': 54, 'output_tokens': 13, 'total_tokens': 67})]" ] }, - "execution_count": 21, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], - "source": ["runnable.invoke(HumanMessage(\"What is your name?\"))"] + "source": [ + "runnable.invoke(HumanMessage(\"What is your name?\"))" + ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [""] + "source": [] } ], "metadata": { From 0d9c0732d49af682c0ff65b96ee8400601cab843 Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Mon, 26 Aug 2024 08:25:24 -0700 Subject: [PATCH 25/30] docs: Update langgraph-api/cloud version constraints --- docs/docs/cloud/deployment/setup.md | 38 +++++++++++-------- docs/docs/cloud/deployment/setup_pyproject.md | 18 +++++---- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/docs/docs/cloud/deployment/setup.md b/docs/docs/cloud/deployment/setup.md index 170e19394..d2d66f86a 100644 --- a/docs/docs/cloud/deployment/setup.md +++ b/docs/docs/cloud/deployment/setup.md @@ -1,15 +1,14 @@ # How to Set Up a LangGraph Application for Deployment -A LangGraph application must be configured with a [LangGraph API configuration file](../reference/cli.md#configuration-file) in order to be deployed to LangGraph Cloud (or to be self-hosted). This how-to guide discusses the basic steps to setup a LangGraph application for deployment using `requirements.txt` to specify project dependencies. +A LangGraph application must be configured with a [LangGraph API configuration file](../reference/cli.md#configuration-file) in order to be deployed to LangGraph Cloud (or to be self-hosted). This how-to guide discusses the basic steps to setup a LangGraph application for deployment using `requirements.txt` to specify project dependencies. This walkthrough is based on [this repository](https://github.com/langchain-ai/langgraph-example), which you can play around with to learn more about how to setup your LangGraph application for deployment. !!! tip "Setup with pyproject.toml" - If you prefer using poetry for dependency management, check out [this how-to guide](./setup_pyproject.md) on using `pyproject.toml` for LangGraph Cloud. +If you prefer using poetry for dependency management, check out [this how-to guide](./setup_pyproject.md) on using `pyproject.toml` for LangGraph Cloud. !!! tip "Setup with a Monorepo" - If you are interested in deploying a graph located inside a monorepo, take a look at [this](https://github.com/langchain-ai/langgraph-example-monorepo) repository for an example of how to do so. - +If you are interested in deploying a graph located inside a monorepo, take a look at [this](https://github.com/langchain-ai/langgraph-example-monorepo) repository for an example of how to do so. The final repo structure will look something like this: @@ -35,23 +34,27 @@ After each step, an example file directory is provided to demonstrate how code c Dependencies can optionally be specified in one of the following files: `pyproject.toml`, `setup.py`, or `requirements.txt`. If none of these files is created, then dependencies can be specified later in the [LangGraph API configuration file](#create-langgraph-api-config). The dependencies below will be included in the image, you can also use them in your code, as long as with a compatible version range: + ``` -langgraph>=0.2.0,<0.3.0 +langgraph>=0.2.7,<0.3.0 +langgraph-checkpoint>=1.0.4 langchain-core>=0.2.27,<0.3.0 langsmith>=0.1.63 -orjson>=3.10.1 -httpx>=0.27.0 -tenacity>=8.3.0 -uvicorn>=0.29.0 +orjson>=3.9.7 +httpx>=0.25.0 +tenacity>=8.0.0 +uvicorn>=0.26.0 sse-starlette>=2.1.0 -uvloop>=0.19.0 -httptools>=0.6.1 -jsonschema-rs>=0.18.0 +uvloop>=0.18.0 +httptools>=0.5.0 +jsonschema-rs>=0.16.3 croniter>=1.0.1 -structlog>=24.4.0 +structlog>=23.1.0 +redis>=5.0.0,<6.0.0 ``` Example `requirements.txt` file: + ``` langgraph langchain_anthropic @@ -62,6 +65,7 @@ langchain_openai ``` Example file directory: + ```bash my-app/ ├── my_agent # all project code lies within here @@ -73,6 +77,7 @@ my-app/ Environment variables can optionally be specified in a file (e.g. `.env`). See the [Environment Variables reference](../reference/env_var.md) to configure additional variables for a deployment. Example `.env` file: + ``` MY_ENV_VAR_1=foo MY_ENV_VAR_2=bar @@ -94,7 +99,6 @@ Implement your graphs! Graphs can be defined in a single file or multiple files. Example `agent.py` file, which shows how to import from other modules you define (code for the modules is not shown here, please see [this repo](https://github.com/langchain-ai/langgraph-example) to see their implementation): - ```python # my_agent/agent.py from typing import TypedDict, Literal @@ -125,9 +129,10 @@ graph = workflow.compile() ``` !!! warning "Assign `CompiledGraph` to Variable" - The build process for LangGraph Cloud requires that the `CompiledGraph` object be assigned to a variable at the top-level of a Python module (alternatively, you can provide [a function that creates a graph](./graph_rebuild.md)). +The build process for LangGraph Cloud requires that the `CompiledGraph` object be assigned to a variable at the top-level of a Python module (alternatively, you can provide [a function that creates a graph](./graph_rebuild.md)). Example file directory: + ```bash my-app/ ├── my_agent # all project code lies within here @@ -147,6 +152,7 @@ my-app/ Create a [LangGraph API configuration file](../reference/cli.md#configuration-file) called `langgraph.json`. See the [LangGraph CLI reference](../reference/cli.md#configuration-file) for detailed explanations of each key in the JSON object of the configuration file. Example `langgraph.json` file: + ```json { "dependencies": ["./my_agent"], @@ -160,7 +166,7 @@ Example `langgraph.json` file: Note that the variable name of the `CompiledGraph` appears at the end of the value of each subkey in the top-level `graphs` key (i.e. `:`). !!! warning "Configuration Location" - The LangGraph API configuration file must be placed in a directory that is at the same level or higher than the Python files that contain compiled graphs and associated dependencies. +The LangGraph API configuration file must be placed in a directory that is at the same level or higher than the Python files that contain compiled graphs and associated dependencies. Example file directory: diff --git a/docs/docs/cloud/deployment/setup_pyproject.md b/docs/docs/cloud/deployment/setup_pyproject.md index 767171f05..aef373a9b 100644 --- a/docs/docs/cloud/deployment/setup_pyproject.md +++ b/docs/docs/cloud/deployment/setup_pyproject.md @@ -35,19 +35,21 @@ Dependencies can optionally be specified in one of the following files: `pyproje The dependencies below will be included in the image, you can also use them in your code, as long as with a compatible version range: ``` -langgraph>=0.2.0,<0.3.0 +langgraph>=0.2.7,<0.3.0 +langgraph-checkpoint>=1.0.4 langchain-core>=0.2.27,<0.3.0 langsmith>=0.1.63 -orjson>=3.10.1 -httpx>=0.27.0 -tenacity>=8.3.0 -uvicorn>=0.29.0 +orjson>=3.9.7 +httpx>=0.25.0 +tenacity>=8.0.0 +uvicorn>=0.26.0 sse-starlette>=2.1.0 -uvloop>=0.19.0 -httptools>=0.6.1 -jsonschema-rs>=0.18.0 +uvloop>=0.18.0 +httptools>=0.5.0 +jsonschema-rs>=0.16.3 croniter>=1.0.1 structlog>=24.4.0 +redis>=5.0.8,<6.0.0 ``` Example `pyproject.toml` file: From 49c316578b835a6bbbd5484cc301ebdf6fabad57 Mon Sep 17 00:00:00 2001 From: Vadym Barda Date: Mon, 26 Aug 2024 12:51:18 -0400 Subject: [PATCH 26/30] langgraph: allow END end key in add_edge with list inputs (#1478) --- libs/langgraph/langgraph/graph/state.py | 2 +- .../tests/__snapshots__/test_pregel.ambr | 70 +++++++++++++++++++ libs/langgraph/tests/test_pregel.py | 18 +++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index 94de80284..73b7df783 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -374,7 +374,7 @@ class StateGraph(Graph): raise ValueError(f"Need to add_node `{start}` first") if end_key == START: raise ValueError("START cannot be an end node") - if end_key not in self.nodes: + if end_key != END and end_key not in self.nodes: raise ValueError(f"Need to add_node `{end_key}` first") self.waiting_edges.add((tuple(start_key), end_key)) diff --git a/libs/langgraph/tests/__snapshots__/test_pregel.ambr b/libs/langgraph/tests/__snapshots__/test_pregel.ambr index 4835ec611..91096bca6 100644 --- a/libs/langgraph/tests/__snapshots__/test_pregel.ambr +++ b/libs/langgraph/tests/__snapshots__/test_pregel.ambr @@ -2279,6 +2279,76 @@ ''' # --- +# name: test_conditional_state_graph_with_list_edge_inputs + ''' + { + "nodes": [ + { + "id": "__start__", + "type": "schema", + "data": "__start__" + }, + { + "id": "A", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "utils", + "RunnableCallable" + ], + "name": "A" + } + }, + { + "id": "B", + "type": "runnable", + "data": { + "id": [ + "langgraph", + "utils", + "RunnableCallable" + ], + "name": "B" + } + }, + { + "id": "__end__", + "type": "schema", + "data": "__end__" + } + ], + "edges": [ + { + "source": "A", + "target": "__end__" + }, + { + "source": "B", + "target": "__end__" + }, + { + "source": "__start__", + "target": "A" + }, + { + "source": "__start__", + "target": "B" + } + ] + } + ''' +# --- +# name: test_conditional_state_graph_with_list_edge_inputs.1 + ''' + graph TD; + A --> __end__; + B --> __end__; + __start__ --> A; + __start__ --> B; + + ''' +# --- # name: test_conditional_state_graph[postgres] '{"title": "LangGraphInput", "type": "object", "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"title": "Agent Outcome", "anyOf": [{"$ref": "#/definitions/AgentAction"}, {"$ref": "#/definitions/AgentFinish"}]}, "intermediate_steps": {"title": "Intermediate Steps", "type": "array", "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": [{"$ref": "#/definitions/AgentAction"}, {"type": "string"}]}}}, "definitions": {"AgentAction": {"title": "AgentAction", "description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "type": "object", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"title": "Tool Input", "anyOf": [{"type": "string"}, {"type": "object"}]}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentAction", "enum": ["AgentAction"], "type": "string"}}, "required": ["tool", "tool_input", "log"]}, "AgentFinish": {"title": "AgentFinish", "description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "type": "object", "properties": {"return_values": {"title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentFinish", "enum": ["AgentFinish"], "type": "string"}}, "required": ["return_values", "log"]}}}' # --- diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 9f6a0c0e5..ba91c4eb0 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -3590,6 +3590,24 @@ def test_conditional_state_graph( ] +def test_conditional_state_graph_with_list_edge_inputs(snapshot: SnapshotAssertion): + class State(TypedDict): + foo: Annotated[list[str], operator.add] + + graph_builder = StateGraph(State) + graph_builder.add_node("A", lambda x: {"foo": ["A"]}) + graph_builder.add_node("B", lambda x: {"foo": ["B"]}) + graph_builder.add_edge(START, "A") + graph_builder.add_edge(START, "B") + graph_builder.add_edge(["A", "B"], END) + + app = graph_builder.compile() + assert app.invoke({"foo": []}) == {"foo": ["A", "B"]} + + assert json.dumps(app.get_graph().to_json(), indent=2) == snapshot + assert app.get_graph().draw_mermaid(with_styles=False) == snapshot + + def test_state_graph_w_config_inherited_state_keys(snapshot: SnapshotAssertion) -> None: from langchain_core.agents import AgentAction, AgentFinish from langchain_core.language_models.fake import FakeStreamingListLLM From da806c466d80bd25be3680467c0d52cf62d1d12e Mon Sep 17 00:00:00 2001 From: Alexander Kovrigin Date: Mon, 26 Aug 2024 19:47:50 +0200 Subject: [PATCH 27/30] Allow passing `ToolNode` as `tools` in `create_react_agent` (#1451) --- .../langgraph/prebuilt/chat_agent_executor.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/libs/langgraph/langgraph/prebuilt/chat_agent_executor.py b/libs/langgraph/langgraph/prebuilt/chat_agent_executor.py index 546ad53bc..d5a1dc88e 100644 --- a/libs/langgraph/langgraph/prebuilt/chat_agent_executor.py +++ b/libs/langgraph/langgraph/prebuilt/chat_agent_executor.py @@ -130,7 +130,7 @@ def _get_model_preprocessing_runnable( @deprecated_parameter("messages_modifier", "0.1.9", "state_modifier", removal="0.3.0") def create_react_agent( model: LanguageModelLike, - tools: Union[ToolExecutor, Sequence[BaseTool]], + tools: Union[ToolExecutor, Sequence[BaseTool], ToolNode], *, state_schema: Optional[StateSchemaType] = None, messages_modifier: Optional[MessagesModifier] = None, @@ -144,7 +144,7 @@ def create_react_agent( Args: model: The `LangChain` chat model that supports tool calling. - tools: A list of tools or a ToolExecutor instance. + tools: A list of tools, a ToolExecutor, or a ToolNode instance. state_schema: An optional state schema that defines graph state. Must have `messages` and `is_last_step` keys. Defaults to `AgentState` that defines those two keys. @@ -419,8 +419,13 @@ def create_react_agent( if isinstance(tools, ToolExecutor): tool_classes = tools.tools + tool_node = ToolNode(tool_classes) + elif isinstance(tools, ToolNode): + tool_classes = tools.tools_by_name.values() + tool_node = tools else: tool_classes = tools + tool_node = ToolNode(tool_classes) model = model.bind_tools(tool_classes) # Define the function that determines whether to continue or not @@ -474,7 +479,7 @@ def create_react_agent( # Define the two nodes we will cycle between workflow.add_node("agent", RunnableLambda(call_model, acall_model)) - workflow.add_node("tools", ToolNode(tool_classes)) + workflow.add_node("tools", tool_node) # Set the entrypoint as `agent` # This means that this node is the first one called From 25a72e77efb00c8dbed907e5e1baaad77295388f Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Mon, 26 Aug 2024 11:57:57 -0700 Subject: [PATCH 28/30] Override with_config to store config in Pregel instance - This enables eg customizing callbacks/metadata in langgraph cloud deployments --- libs/langgraph/langgraph/pregel/__init__.py | 34 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 73db1bfb8..f127859ff 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -38,6 +38,7 @@ from langchain_core.runnables.config import ( ensure_config, get_async_callback_manager_for_config, get_callback_manager_for_config, + merge_configs, patch_config, ) from langchain_core.runnables.utils import ( @@ -219,11 +220,20 @@ class Pregel( config_type: Optional[Type[Any]] = None + config: Optional[RunnableConfig] = None + name: str = "LangGraph" class Config: arbitrary_types_allowed = True + def with_config( + self, config: RunnableConfig | None = None, **kwargs: Any + ) -> Runnable[Dict[All, Any] | Any, Dict[All, Any] | Any]: + return self.copy( + update={"config": cast(RunnableConfig, {**(config or {}), **kwargs})} + ) + @classmethod def is_lc_serializable(cls) -> bool: """Return whether the graph can be serialized by Langchain.""" @@ -297,6 +307,7 @@ class Pregel( def get_input_schema( self, config: Optional[RunnableConfig] = None ) -> Type[BaseModel]: + config = merge_configs(self.config, config) if isinstance(self.input_channels, str): return super().get_input_schema(config) else: @@ -316,6 +327,7 @@ class Pregel( def get_output_schema( self, config: Optional[RunnableConfig] = None ) -> Type[BaseModel]: + config = merge_configs(self.config, config) if isinstance(self.output_channels, str): return super().get_output_schema(config) else: @@ -342,6 +354,7 @@ class Pregel( if not self.checkpointer: raise ValueError("No checkpointer set") + config = merge_configs(self.config, config) if self.config else config saved = self.checkpointer.get_tuple(config) checkpoint = saved.checkpoint if saved else empty_checkpoint() config = saved.config if saved else config @@ -374,6 +387,7 @@ class Pregel( if not self.checkpointer: raise ValueError("No checkpointer set") + config = merge_configs(self.config, config) if self.config else config saved = await self.checkpointer.aget_tuple(config) checkpoint = saved.checkpoint if saved else empty_checkpoint() @@ -422,7 +436,12 @@ class Pregel( metadata, parent_config, pending_writes, - ) in self.checkpointer.list(config, before=before, limit=limit, filter=filter): + ) in self.checkpointer.list( + merge_configs(self.config, config) if self.config else config, + before=before, + limit=limit, + filter=filter, + ): with ChannelsManager( self.channels, checkpoint, config, skip_context=True ) as (channels, managed): @@ -467,7 +486,12 @@ class Pregel( metadata, parent_config, pending_writes, - ) in self.checkpointer.alist(config, before=before, limit=limit, filter=filter): + ) in self.checkpointer.alist( + merge_configs(self.config, config) if self.config else config, + before=before, + limit=limit, + filter=filter, + ): async with AsyncChannelsManager( self.channels, checkpoint, config, skip_context=True ) as (channels, managed): @@ -504,6 +528,7 @@ class Pregel( raise ValueError("No checkpointer set") # get last checkpoint + config = merge_configs(self.config, config) if self.config else config saved = self.checkpointer.get_tuple(config) checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() checkpoint_previous_versions = ( @@ -637,6 +662,7 @@ class Pregel( raise ValueError("No checkpointer set") # get last checkpoint + config = merge_configs(self.config, config) if self.config else config saved = await self.checkpointer.aget_tuple(config) checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint() checkpoint_previous_versions = ( @@ -885,7 +911,7 @@ class Pregel( {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'result': [('alist', ['there'])]}} ``` """ - config = ensure_config(config) + config = ensure_config(merge_configs(self.config, config)) callback_manager = get_callback_manager_for_config(config) run_manager = callback_manager.on_chain_start( dumpd(self), @@ -1125,7 +1151,7 @@ class Pregel( {'type': 'task_result', 'timestamp': '2024-06-23T...+00:00', 'step': 2, 'payload': {'id': '...', 'name': 'b', 'result': [('alist', ['there'])]}} ``` """ - config = ensure_config(config) + config = ensure_config(merge_configs(self.config, config)) callback_manager = get_async_callback_manager_for_config(config) run_manager = await callback_manager.on_chain_start( dumpd(self), From 3979bdb792a2f1605b954cca93ea9ffd78e84edd Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Mon, 26 Aug 2024 13:14:11 -0700 Subject: [PATCH 29/30] Type as self --- libs/langgraph/langgraph/pregel/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index f127859ff..e871815ea 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -227,9 +227,7 @@ class Pregel( class Config: arbitrary_types_allowed = True - def with_config( - self, config: RunnableConfig | None = None, **kwargs: Any - ) -> Runnable[Dict[All, Any] | Any, Dict[All, Any] | Any]: + def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self: return self.copy( update={"config": cast(RunnableConfig, {**(config or {}), **kwargs})} ) From 1d0f3577a7e9ee7e5086352560298791e55db9ff Mon Sep 17 00:00:00 2001 From: Nuno Campos Date: Mon, 26 Aug 2024 13:17:19 -0700 Subject: [PATCH 30/30] Catch any type error when reviving saved values --- libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index d52bfc8c1..e39a66e76 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -150,7 +150,7 @@ class JsonPlusSerializer(SerializerProtocol): return method(**value["kwargs"]) else: return method() - except (ImportError, AttributeError): + except (ImportError, AttributeError, TypeError): return None return LC_REVIVER(value)