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/_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/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..cd6248a8f 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,12 +99,11 @@ 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 -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 +114,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, @@ -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..9c68ec198 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: @@ -109,7 +111,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 +122,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/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 +}' +``` 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/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": "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", "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": "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", + "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": { 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": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAGDAGsDASIAAhEBAxEB/8QAHQABAAIDAQEBAQAAAAAAAAAAAAYIBAUHAwIJAf/EAFQQAAEDAwEDBQkKCgcGBwAAAAECAwQABREGBxIhCBMWMUEUIjdRVmGUtNMVFzJVcXV2k5XRIzVCUlSBkbPS1AkkM1N0krElQ2Jyw/Bjg6GipLLB/8QAGwEBAQADAQEBAAAAAAAAAAAAAAECAwQFBgf/xAA2EQACAAMEBggFBAMAAAAAAAAAAQIDERIhMVEEUmFxkcEFExQjQVOh0RUzQqLhMoGx4pLw8f/aAAwDAQACEQMRAD8A/VOlK0V2u0uTcBaLSEiWEhcmY4N5uIg9XD8pxX5KeoAFSuG6lecMLjdEXE3L8hqM2XHnENIHWpagkD9ZrXnVNlBwbvAB/wASj76wGdn9lKw9cIovczGFSrqA+s8c8ARuo+RCUjzVnDStlAx7jwMf4VH3VtpJWLbFx/elVl+OIHpKPvp0qsvxxA9JR99Oitl+J4HoyPup0VsvxPA9GR91O52+hbh0qsvxxA9JR99OlVl+OIHpKPvp0VsvxPA9GR91Oitl+J4HoyPup3O30Fw6VWX44geko++nSqy/HED0lH306K2X4ngejI+6nRWy/E8D0ZH3U7nb6C4yYd2g3AkRZkeSR2MupX/oay60UzQmnJ4/DWO3qV2OJjIStPnSoAEHzg1huomaLBfS/JuljB/DNPq5x+Gn89CvhOIHWUqKlAZIJwE0sQR3QO/J+/8AwlE8CU0r5bcQ82lxtSVoUApKknIIPUQa+q5yHnIfRGYcecOENpK1HxADJrQbP2VHTEW4PAd2XUe6MhQzxW4AQOP5qdxA8yBW6uUTu+3Soucc+0tvPiyCP/2tVoKV3XouyrIKXERG2nEqGClxA3FpI8ykkfqroV0l0zXMvgb6lKVzkI7rraDp/ZrYxd9SXAW6Cp5EZtQaW6466s4Q2222lS1qODhKQTwPirm+suVNpnTE7Z+qMzPudp1VIlNmZHtkxbkdDLbpUQyhhS1L5xsIKMBQG8ojCSa3fKFtNou2iIgu9q1LcBHuTEmJJ0lHU9cLdIQFFEptKcnveIOEq+HgpIJrkZnbQXdPbH9b6t09erxJ09qGeZrUO2f7TXBdjyY8eS7EbyUrIW2VoSMjezgcQAOz6z5QWgtntzjwNQ3xdskPR25X4SBJU2y0skIW8tLZSyCQRlwp6j4q99T7c9FaP1MjTtyu7vu45EanNwIcCTLdcYcWtCXEpZbXvJy2rJHwcAqwCCeC7cxqvaBcda22XaNev2q56caRpS12Jl6NFdeejr573QWkpCVpcKUlp9QTuA4Sok1MNimn7ona7AvU2yXGEx729mgd0zoTjO5IS++XWCVJGHE94VI6x3p7RQEw2W8oK1bTNbav001BnwplkujsFlbkCUGn222mlKcU6plLbat5xQDZVvEJChkKBrq9cP2TyLhova/tI09c9PXpKNQagVerfeGoK3LcthUJhJCpAG6hYUwpO6rBJKcZzXcKAUpSgIxobEFq62ROA1aJhjR0pzhLCm0OtJGexKXAgeZFSeozpJPdF61TPTnmnrgGWyRjIaZbbUfP34cH6qk1dE/5je6u+l/qV4iou8FaNuUqWG1LsU1wvSObSVKhvHG84QP90rGVEfAVlRylSlIlFK1wR2ap3pgiuqNnujNqDECTqDT9m1QywlSojs6K3JShK8bxQVA4Ct1OcdeBWhHJt2UBJT72+lt0kEj3JYwT2fk+c1JZOgrW4+4/DVLs7zhJWq2SVsJUScklsHcJJ45Kc9fHia8uhMjs1Tfh/wCcz7KtliU8Iqb17VFx8aQ2UaL2fzH5emdKWewSn2+adetsJtha0ZzukpAyMgHFSuov0JkeVV++uZ9lToTI8qr99cz7KnVy9f0YosyUUqvu2K9ah0JtE2UWS26nuioep7w7BnF9TSlhtLJWNwhsbpz2kGutdCZHlVfvrmfZU6uXr+jFFmbfUGnbXquzybTerdGutskgB6HMaS604AQoBSVAg4IB+UCoSjk3bKWySjZxpdJIIyLSwOBGCPg+I1v+hMjyqv31zPsqdCZHlVfvrmfZU6uXr+jFFmam0bAdmlgukW5W3QOnIFwiuJeYlRrYyhxpYOQpKgnIIPaK312v7kmS5abItuRdc7rrvwmoKT1rd/4sfBb61HHUneUnHOgmZHCbeb1PbPAtOTlNJV8vNbmR5uo9tb63WyJaIiIsKM1EjpyQ2ygJGT1nh2ntPbTu4L07T9Bcj4s1pj2K1RbfFCgxHQEJKzvKV41KPaonJJ7SSazaUrQ24nV4kFKUqAUpSgFKUoCu/KW8NHJ7+ksj1Y1Yiq78pbw0cnv6SyPVjViKAUpSgFKUoBSlKAUpSgFKUoBSlKArvylvDRye/pLI9WNWIqu/KW8NHJ7+ksj1Y1YigFKUoBSlKAUpSgFKUoBSsO73WPZLc9NlKUlloDIQkqUokgJSkDiSSQAO0kVFl6h1W9hbNptUdtXENSJrinEjH5W63jPjAJHiJrfLkxzFVYbXQtCa1i3S1xL3bJlunsIlQZjK48hhwZS42tJSpJHiIJH66iXu7rD9Asfpb3s6e7usP0Cx+lvezrb2WPNcUKH4vconY7M2F7X9QaSkpWY0d4uwH3P9/EXxaXnGCd3grHAKSodlfq1yHdjcjYtyfbRAnhbd2vDir1NYWCCy46hAS3g9RS222FD87erR7ZuTy7tu17onVV7gWZEzTb++ppD7ikz2QrfSw7lr4AWM/IpY/KyOx+7usP0Cx+lvezp2WPNcUKE3pUI93dYfoFj9Le9nX9F81fkZgWQDtxLe9nTssea4oUJtStDp7UrtylOwLhFTAubTYd5tt0utOtnhvtrKUkgHgQQCDjhgpJ31c0cEUt2YiClKVgBSlKAiW0w/7AhDrButvyD/AIpqsqsXaZ+IYXztb/Wm6j+1WVqeFs51C/ouO1K1S3EWq3svAFKnezgSATjOATgnGeFelL+RDvfIyeCJQ44hpBWtSUJHWpRwBX1VQ9o18na62BvJTtAu1xucLVVpamNz7NHgT4SlSo4DD7HN4BStXOpUAArdSMqTvb042m651vYNYaa2dWCffbtclWl673G926BbnJ7yEvJaQlLb6mY6RlR3iEk8EAJ4lQxtGJYSlVna1ntXlydnmnrzOlaPuN3v9wgOznIMJcmZCahLfZdU0lTzTbhKd0hKsZRnBB3T0TY7qvUEjVuvdGajuSb9L0xJi8xeBHRHXJYkMB1CXEIAQHEEKBKQARunAooqg6pXhDnxbi24uJJZkobcWytTKwsJWlRStJI6lAggjrBGKwtUWI6msEy1i4zbUJSQhUu2u81IQnIKtxeDukgEZHEZ4YODXJ+R9AZtWxkQowUmPGvd3ZbC1lZCUz3wMqJJJwOsnJrKt9AdWaONpVk89rn5+tiVOagrXhLsfzXP/exKnVadJ+jdzZX4ClKVxkFKUoCJbTPxDC+drf603WNqWxjUthm2sz51r7pb3O7La/zMhnjnebXg7p4eKt5q+yPX6xuR4ykIltutSWOd4ILjTiXEpUcHAUU7pIBIByASKjK9ViP3sqzXuO+OCm02x58JPb37SVJPygkV6clW5Shhvab5GWKuIMjk4acd0rqezXG5Xu8ydRvsSZ96my0mcp1jc7nWhaEJSgtltJThOOHHOa97zsDt98iWFyRqjUyNR2XnkxdTsy2kXEtunK2lqDXNrQeHeqbIG6MYNTDpnG+LL99iS/ZU6Zxviy/fYkv2VbOoj1WLLyOWbQdid3u102WQLZer+7CslxmyZ+oVXFs3FgORXgle+4O+y4tKN1KCAk43QkcJFZNn9z2RW99GjLY3q243WUuZd7pqe+LYlPu7qUoUVojOBWEjdCQlCUhIwOJrf3Taxp+yTbdDuJuUCXcnSxCjybXJbclOAZKG0lsFagOOBk1sumcb4sv32JL9lU6iPVYsvIx9J3TWE+W8jUenbRZoyUZadt15cmqWvPUUqjNboxnjk/JX1s90Bb9m2n12e2PSX4y5kmaVy1JUvffeW8sZSlIwFLIHDOMZJ669umcb4sv32JL9lX9TrGMpQAtl9yTjjZJY/wCnV6mZqsllnu14S7H81z/3sSp1UO03BlXbUCL5IiOwI0eK5FitSU7rznOKbUtak9aAObSADxPfEgAJJmNcektVhhyXNvmGKUpXIQUpSgFKUoBSlKArvylvDRye/pLI9WNWIqu/KW8NHJ7+ksj1Y1YigFKUoBSlKAUpSgFKUoBSlKAUpSgK78pbw0cnv6SyPVjViKrvylvDRye/pLI9WNWIoBSlKAUpSgFKUoBSlKAUpXwt5ts4WtKT14JxQH3WJd35kW1TXrfFROntsLXHiuPcyl5wJJSgr3VbgJwN7BxnOD1V7d1M/wB83/mFO6mf75v/ADCrRg/LXav/AEhT+tNf6EusrZwuzydF3Z2Y7BdvBWp9RQWy0SY6S2Qe3CvFir48l7b1J5R2zZ3Vz+mF6Va7vdhsR1TO6g+hCUEupXzbfDeUtGMHi2ePYKM8ubktT3+UdYpmk46VxdoEoN94PwcedkB5SyB3qVJIdJP/AIp6k1+jezbRdm2XaDsWlLOptFvtMVEZs5AKyB3zisflKUVKPnUaUYJTSvLupn++b/zCndTP983/AJhSjB60pSoBSlKAUpSgI9ru6SLVp8qiumPIkSY8RLwAJb515DZUMgjeAUSMgjIGeFR7oBppQ/C2K3yVk5U7KjpecWcAbylrBUonAySSTW12mfiGF87W/wBabrw1HqK2aRsU683ma1brXBaU/IlPHCG0DrJ+4cT1CvUkxOXJThdKt8jLBXGD732lvJqz+gNfw0977S3k1Z/QGv4a51rflP6Z09s86V2hEy9R/dWLaVNKt8tlba3Vo3ipCmd8bra98ZSAs7qQcqTmW3nbRpDT2n7VeLlcZESNdSoQmHLdJ7sfKfhbsXm+e4dZ7zgCCesVn2iZrviSrzNx732lvJqz+gNfw0977S3k1Z/QGv4ajkvb/oGHZbLdjfw/CvL7sWCqLEffW682kqW1zbaFLSsBJ71QByMYzwqQ6I2gWDaNanbjp64pnxmXlRngW1tOsOpxvNuNrSlaFDIO6oA4I8dXr5mu+Iq8z6977S3k1Z/QGv4a/qdAaXQoKTpu0JUDkEQWsj/21865d1Q3YVDSDFrevS3UISq8LcTHaQT36yGxvLIHUkFOfGO2KbHdod+1bc9ZWDU0a3C86YuDcJ6bZ+cESUHGUPJKErJUlSQvdUkqOCOvjU6+ZhafEVeZNdNob03qxizQgGbXMhPSEQ0/2bDjS2kktjqSlQdGUjhlIIAJUTOagrXhLsfzXP8A3sSp1XJpV7hieLXNoMUpSuMgpSlARLaZ+IYXztb/AFpuo7tXtdrvWznUEG9WadqG1PxVIkW22tlcl9JI4NAEErHAjBByOFSTaWgq09FV+Si6QFqOM4HdTXH/AL/XwrIr0pd8hb3yMvAqfPja+1XsZ1bFdt2o79bbPfLXMsRvsHua8zYjD8d99C2iElakbiwlRSFLx2mtntMjSNT7RdI7RHrBrtzSjlolWh+LZEzYF1gPl9K0urYZUh5TawgggA9SFEcBVnaVjZMSp96Zsmze+bHr3adN6rYYnakutxlW64Jfm3Z11dveaU6pC1rcJKUJXu5zu8cb2RXTthlvul01ptI1vMss/Ttu1JNiCBb7ozzEpSI8cNKfca60b6s4CsKwgZArpV30jab9erHdp0Tn7hZHnJEB7nFp5lbjSmlnAICsoWoYUCOORx41j6t2e6Y18iKjUun7bfkxSosJuMVD4aKsb27vA4zgZx4hSzQGq2ya3uezzZ1eL1ZLDcNS3ptvm4Ntt0RyS468rggqS2CQgHvlHxA9pFQ3kwyokPSDtnTaNTxbwlZuF3umorK/ANxmvqKnnUlxI3u+GAB8FIQK6BpPZdo/Qkt6VpzS9osUl5HNOvW6E2wpaM53SUgZGQDipPVo61BqGvCXY/muf+9iVOqg7CCvaTaFJ483a5u8MHhvOxcf/U/s+WpxWrSfo3c2V+ApSlcZBSlKA8ZkNi4RHosplEiM8gtuNOpCkrSRggg9YIqLL2fyEd5F1VeojA+C1/Vnt0dg33WVLPV1qUT4yal9K3QTY5d0L5/yWtCG9ALh5Z3v6iF/L06AXDyzvf1EL+XqZUrZ2mZs4L2FSvO1276l0BtA2W2KBqqe/E1Vd3YExcmNEK220tFYLZDIAVnxgjzV1ToBcPLO9/UQv5euU8pbw0cnv6SyPVjViKdpmbOC9hUhvQC4eWd7+ohfy9f1OgrglQJ1lelAHqLMLB/+PUxpTtMzZwXsKmosGmo1gDy0OvzJj+6H5stYU66E53QcABKRlWEpASCpRxlSidvSlc8UTjdqJ3kFKUrEClKUApSlAKUpQFd+Ut4aOT39JZHqxqxFV35S3ho5Pf0lkerGrEUApSlAKUpQClKUApSlAKUpQClKwr1ZoWorNPtNyjol26fHciyY7nwXWlpKVpPmIJH66A4JylvDRye/pLI9WNWIr8H9veyWbsQ2s6h0fM33EQXyYshQ/t46xvNL6sZKCMgdRyOyv1Z5B2yGXse5PFpi3ION3S9vqvcmO4MFguobShGOsENttkg8QoqHZQFh6UpQClKUApSlAKUpQGLdLnGs1vfmy3Oajsp3lqCSo+YBIyVEnAAAJJIA4mosvVmpXe/jaahIbVxCJt1LboHZvBtlxIPjAUR5zXvtLURYIY4EG628EEZB/rTVZNehJggUtRxQ1q3n4UyazMsEa3pRq3ycs/207/K06Uat8nLP9tO/ytbBxxDSCtakoSOtSjgCvqtvdeWvu9yV2FeNu/Jxf29bQ9Faqu9ks8ZywO4lxRcluC5xwoLQwtRjDdSFb3HB4LWMcQR3XpRq3ycs/wBtO/ytbKlO68tfd7iuw1vSjVvk5Z/tp3+Vr+p1PqwqG9p20BOeJF6dJ9Vr2ut2g2K3vz7lNj2+CwnfdlSnUtNNp8alKIAHy142HUdp1VbW7jZbpDvFvcJCJcCQh9pRHXhaCQf207ry1xi9xXYbfT2pjd3nocuIq3XNlIcXHUsOIWg8AttYA3k5yDwBB6wAUlW9qDMqKdpVlAwN61zsnHE4diY4/rqc1xz4IYIk4bk1X+VyDFKUrmIKUpQES2mfiGF87W/1puo/tVlanhbOdQv6LjtStUtxFqt7LwBSp3s4EgE4zgE4JxnhUg2mfiGF87W/1pusbUtjGpbDNtZnzrX3S3ud2W1/mZDPHO82vB3Tw8VelB8iHe+Rk8EVW2jXydrrYG8lO0C7XG5wtVWlqY3Ps0eBPhKVKjgMPsc3gFK1c6lQACt1IypO9vTjabrnW9g1hprZ1YJ99u1yVaXrvcb3boFucnvIS8lpCUtvqZjpGVHeISTwQAniVCXI5OGnHdK6ns1xuV7vMnUb7EmfepstJnKdY3O51oWhCUoLZbSU4Tjhxzmve87A7ffIlhckao1MjUdl55MXU7MtpFxLbpytpag1za0Hh3qmyBujGDWujMTmzWs9q8uTs809eZ0rR9xu9/uEB2c5BhLkzITUJb7LqmkqeabcJTukJVjKM4IO6eibHdV6gkat17ozUdyTfpemJMXmLwI6I65LEhgOoS4hACA4ghQJSACN04FaHaDsTu92umyyBbL1f3YVkuM2TP1Cq4tm4sByK8Er33B32XFpRupQQEnG6EjhIrJs/ueyK3vo0ZbG9W3G6ylzLvdNT3xbEp93dSlCitEZwKwkboSEoSkJGBxNVVTB0a52qFe4TkO4w48+G4UlceU0lxtRSoKSSlQIOCAR5wDXDeTUwu3662sRLnbGNN6jcucSZLsFvIVBjMrjBDLrKxjnC6G1qWrdQd4EFIxkzt22av19ap9o1JBb0bHcShbNz0vqJx6WlxK0qAG9FbCRw453gRkFJBNZezjZNa9mz94mx51zvd5vDja7hd7zJD8qRzaSltJKUpSEoBICUpAGTVxaYN+14S7H81z/AN7EqdVBWvCXY/muf+9iVOq16T9G7myvwFKUrjIKUpQGj1lZn75YlMRdzutl9iUylw4StbTqXAknBwFbu7nBxnPZUaXrSDH7yVGucR8fCZctsgqSfFlKCk/KkkHsJroNK6pc5QQ2IlVb6cmWuZzzp3afFcPsuV7OnTu0+K4fZcr2ddDpW3tErUfH8C45fO2raYtcqFGmTnokma4WorL8GQhb6wMlKAUZUcccCs3p3afFcPsuV7Oudcpbw0cnv6SyPVjViKdolaj4/gXHPOndp8Vw+y5Xs6J11alEACfk8ONskj/p10OlO0StR8fwLiGacjPXrUjd8MZ+JCjRHIscSmlNOvFxTalr3FAKSkc0kDewSSrhgJJmdKVyzJnWOvgGKUpWogpSlAKUpQClKUBXflLeGjk9/SWR6sasRVd+Ut4aOT39JZHqxqxFAKUpQClKUApSlAKUpQClKUApSsC/zJtvsVxlWyCm6XJiM47Ggqe5kSHUpJQ2XMHc3lADewcZzg0BwblLeGjk9/SWR6sasRX5Y7U/6Q06613oC8u7PXbU7o66uznIbl231SCWy2WySwnmyD2kH5KvbyXOUC5yk9nUvVa9NuaYbauTkBqO5K7pDyUNtq50L5tHDecUnGDxQePYAOw0pSgFKUoBSlKAUpWPcJ8e1QX5kt5EeKwguOurOEpSBkk1Um3RAyKVwTVu068amfcat8l6y2nJCAx3kl5PYpS+tvPWEpwR2q7BCX7VGlLK5CVylniVyHVuKPylRJr6ST0JMjhtTYrOyleaLcWxpVSvcC3fojf7Ke4Fu/RG/wBldPwFeb9v9iVRWLlzcly42/lI2dzSsDeibQJQ7nbQDuNzioB8HAO6k7wdJP56+xJr9JtlOzm27I9nVg0haR/UrTFSwHCkJLq+txxQH5S1lSj51Gq7e4Fu/RG/2U9wLd+iN/sp8BXm/b/YVRbWlVK9wLd+iN/soLDb0nIioSfGMg1PgK837fyKotrSqzWTUN60y8hy13aS2hOMxZTipEdY8RQo5T8qCk+frz3HQmuo2tYCyECLco4AlQyre5vOcKScDeQcHBx2EEAgivJ0zoyboit1tQ55by7iUUpSvIIK5HtzvbqpFosTSyllzemyQDjeCCA0k+MbxKvlbTXXK4fttjLY1xbJKv7OTblNI/5m3Mq/9HU/9ivY6Jhhi0uG14Vf70/1lRCaUpX6AaxUQvO1zSWn7y5a594QxKaUlDx5lxTTClY3UuupSUNk5HBSh1ipfVcomi2bddNUWHU9j1ncvdS7yX2nbPLl+58uNIXkFwNuJbQQFELCwOCe2uTSJkculil+daehTrd82w6R05c51vuF2LMuApAloRFecEcKQlaVOKSghKClae/JCesZyCBl6o2maa0c/DZut0Sy/LQXWWmWnH1qbHW5utpUQj/iOB56gL2l5rHv1x2rbKLEyCyzBBZWrukJtqW8Nkj8Id4bvDPHh11gaTVc9nmrGbnc9O3m6R7tp22RWX4EJT7kR1hCg4w4kcW94rCsnAyDk8OGhz5qdGkr3fR3XtX331ossQdH2T6ul682d2S/zm2GpU5kuOIjJKWwd5Q70Ek9QHWTUtqAbBLbMtGyDTMOfEfgTGo6g5Gktltxs84o4Uk8QeNT+uyS25ULixogKz9OXtzTWqLTc21lKEvpjyBnAWw4oIVn/lJSv5UD5DgV4yYq56osNv8AtZUlmOjH5y3EpH+ufNispkMMcDhjwaLDii1lKUr8rKKiu0bRnTOw8ywpLVyir7ohuLOE74BG4ojjuqBKT14yDglIqVUrbKmRSY1Mgd6BVd5oh2TClsKZkMktSIr4wps44pUPEQcgjIIIIJBBqHe8voHyMsf2e1/DVuNVaCsuskoVcYuZLad1uWwotvNjxBY4kcfgnI81Ql7YG1vf1fUlwQjsDzLKz+0JTX2MvpbRZ0K69Ue6q/YURX33l9A+Rli+z2v4amSUhCQlICUgYAHYK6Z7wavKeX6K1T3g1eU8v0VquiHpLQYP0xU/Z+ws7TmlK6X7wavKeX6K1T3g1eU8v0Vqs/i2h6/o/YWdpxK/bOtLaond23jTtsukvcDfPy4qHF7o6hkjOOJrXe8toHyMsX2e1/DXfveDV5Ty/RWq/o2BnPHU8vHmitA/6VqfSPR7dW1/i/YWdpx6waXsukIbrFmtkO0Rlr51xuIylpBVgDeIAAzgDj5q6rsj0S9crhH1LNaU1BYBNvbWCC+pQwXsH8kAkJz8LJUOAQVSix7FrBan0SJhk3x5BCk+6CkqbSR1ENpSlJ8fEHBqfV5em9KwRy3J0ZUTxeF2wYClKV8uBSlKAUpSgFKUoBSlKAUpSgFKUoBSlKA//9k=", + "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 +} 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 3105d3342..7fd810943 100644 --- a/examples/tutorials/rag-agent-testing-local.ipynb +++ b/examples/tutorials/rag-agent-testing-local.ipynb @@ -355,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", 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", diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index 45f0ed360..e80b9a678 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): @@ -146,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) diff --git a/libs/checkpoint/pyproject.toml b/libs/checkpoint/pyproject.toml index 7ceea436e..0cdafc185 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.6" description = "Library with base interfaces for LangGraph checkpoint savers." authors = [] license = "MIT" 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) == { 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( 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 da3862616..3b322d391 100644 --- a/libs/langgraph/langgraph/constants.py +++ b/libs/langgraph/langgraph/constants.py @@ -12,6 +12,7 @@ CONFIG_KEY_TASK_ID = "__pregel_task_id" INTERRUPT = "__interrupt__" ERROR = "__error__" TASKS = "__pregel_tasks" +RUNTIME_PLACEHOLDER = "__pregel_runtime_placeholder__" RESERVED = { INTERRUPT, ERROR, @@ -23,6 +24,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 3f2b72d64..a12796548 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 @@ -382,7 +382,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)) @@ -433,16 +433,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) ] ) @@ -510,7 +508,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) }, ) @@ -531,7 +528,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) + [ @@ -658,7 +655,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 = ( @@ -684,16 +688,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..5ce15aec2 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,45 @@ 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]): + 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(): + 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): + 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] + ): + 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: + values[key] = self[value[RUNTIME_PLACEHOLDER]](step) + elif hasattr(values, "__dir__") and callable(values.__dir__): + for key in dir(values): + 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 diff --git a/libs/langgraph/langgraph/managed/context.py b/libs/langgraph/langgraph/managed/context.py new file mode 100644 index 000000000..43cff5e67 --- /dev/null +++ b/libs/langgraph/langgraph/managed/context.py @@ -0,0 +1,87 @@ +from contextlib import asynccontextmanager, contextmanager +from inspect import signature +from typing import ( + Any, + AsyncContextManager, + AsyncIterator, + ContextManager, + Iterator, + Optional, + Type, + Union, +) + +from langchain_core.runnables import RunnableConfig +from typing_extensions import Self + +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( + "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") + 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/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 diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 496ec1b30..e5648951a 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 ( @@ -51,7 +52,6 @@ from typing_extensions import Self from langgraph.channels.base import ( BaseChannel, ) -from langgraph.channels.context import Context from langgraph.checkpoint.base import ( BaseCheckpointSaver, CheckpointTuple, @@ -71,7 +71,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, @@ -396,11 +401,18 @@ 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) -> Self: + 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.""" @@ -474,6 +486,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: @@ -493,6 +506,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: @@ -511,10 +525,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) ] @property @@ -534,6 +545,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_config = saved.config if saved else config checkpoint_ns_to_graph: dict[str, Pregel] = _get_checkpoint_ns_to_graph(self) @@ -553,6 +565,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_config = saved.config if saved else config checkpoint_ns_to_graph: dict[str, Pregel] = _get_checkpoint_ns_to_graph(self) @@ -594,7 +607,10 @@ class Pregel( checkpoint_tuples = [ checkpoint_tuple for checkpoint_tuple in self.checkpointer.list( - config, before=before, limit=limit, filter=filter + merge_configs(self.config, config) if self.config else config, + before=before, + limit=limit, + filter=filter, ) ] for checkpoint_tuple in checkpoint_tuples: @@ -632,7 +648,10 @@ class Pregel( checkpoint_tuples = [ checkpoint_tuple async for checkpoint_tuple in self.checkpointer.alist( - config, before=before, limit=limit, filter=filter + merge_configs(self.config, config) if self.config else config, + before=before, + limit=limit, + filter=filter, ) ] @@ -667,6 +686,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 = ( @@ -728,7 +748,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() @@ -752,9 +772,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, ), }, ), @@ -787,6 +820,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 = ( @@ -846,7 +880,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() @@ -870,9 +904,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, ), }, ), @@ -1022,7 +1069,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), @@ -1263,7 +1310,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), diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index e3e699518..5b6064306 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,8 @@ 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) 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 +303,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 +328,7 @@ def prepare_next_tasks( # deque.extend is thread-safe CONFIG_KEY_SEND: partial( local_write, + step, writes.extend, processes, channels, @@ -324,8 +336,10 @@ def prepare_next_tasks( ), CONFIG_KEY_READ: partial( local_read, + step, checkpoint, channels, + managed, PregelTaskWrites(packet.node, writes, triggers), config, ), @@ -414,6 +428,7 @@ def prepare_next_tasks( # deque.extend is thread-safe CONFIG_KEY_SEND: partial( local_write, + step, writes.extend, processes, channels, @@ -421,8 +436,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/pyproject.toml b/libs/langgraph/pyproject.toml index bc0b67fb4..35164c2f4 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.14" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" 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_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 6df2403a4..a7642851d 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -3595,6 +3595,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 @@ -4047,7 +4065,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, @@ -4067,6 +4085,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: @@ -4110,6 +4129,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", @@ -4117,16 +4137,26 @@ 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" # 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: + 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) return { @@ -7497,7 +7527,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 6938071de..426bfd730 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, @@ -3882,6 +3885,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, @@ -3890,13 +3899,13 @@ async def test_state_graph_packets() -> None: AIMessage, BaseMessage, HumanMessage, - ToolCall, ToolMessage, ) from langchain_core.tools import tool class AgentState(TypedDict): messages: Annotated[list[BaseMessage], add_messages] + session: Annotated[httpx.AsyncClient, Context(httpx.AsyncClient)] @tool() def search_api(query: str) -> str: @@ -3941,13 +3950,19 @@ 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 [Send("tools", tool_call) for tool_call in tool_calls] + return [ + Send("tools", ToolInput(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: + 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( tool_call["args"], config 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", 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( 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" 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["*"]