Compare commits

..
71 Commits
Author SHA1 Message Date
Nuno Campos 746142fb07 One more 2025-06-02 16:16:11 -07:00
Nuno Campos b9c9c32c31 Allow releases from v0 2025-06-02 16:12:54 -07:00
Nuno Campos c89fe4c45d 0.4.8 2025-06-02 16:11:09 -07:00
Nuno CamposandGitHub b0e28851a6 v0: Fix Command(graph=PARENT) when used together w checkpointer=True (#4920) 2025-06-02 16:10:00 -07:00
Nuno Campos 48fb91deda Fix Command(graph=PARENT) when used together w checkpointer=True 2025-06-02 16:02:35 -07:00
Nuno CamposandGitHub efb282a197 Remove SchemaCoercionMapper (#4855) 2025-05-28 10:51:32 -07:00
Nuno Campos 89ce6ea2a8 Remove SchemaCoercionMapper 2025-05-28 10:42:43 -07:00
DHParkandGitHub 1e87312d1f Update graph-api.ipynb (#4824) 2025-05-28 13:39:22 +00:00
vksxandGitHub bcc6485f6c (docs) fix broken links (#4823) 2025-05-28 13:36:48 +00:00
Sydney RunkleandGitHub ce5f248e3b docs: fix typo (#4852)
minor fix
2025-05-28 13:35:17 +00:00
Sydney RunkleandGitHub 5602c29668 prebuilt: release 0.2.2 (#4851)
bump prebuilt
2025-05-28 13:33:39 +00:00
Sydney RunkleandGitHub 19ca6b416b fix: post_model_hook should inject state + store into tool calls if necessary (#4842) 2025-05-27 17:42:20 +00:00
Lauren Hirata SinghandGitHub 2553ae0b87 docs: Fix links (#4807) 2025-05-23 22:34:08 -04:00
Lauren Hirata Singh 3e4b69af3f Fix links 2025-05-23 22:30:43 -04:00
Lauren Hirata SinghandGitHub 0c6367c186 docs: fix link to examples page in readme.md (#4710) 2025-05-23 22:26:30 -04:00
Lauren Hirata SinghandGitHub 99860b5713 Update README.md 2025-05-23 22:12:18 -04:00
Lauren Hirata SinghandGitHub a69860baa6 Merge branch 'main' into fix-examples-link-readme 2025-05-23 22:08:32 -04:00
Lauren Hirata SinghandGitHub 6fc21046cd Update libs/langgraph/README.md 2025-05-23 22:06:43 -04:00
Lauren Hirata SinghandGitHub 754420e9a2 Update libs/langgraph/README.md 2025-05-23 22:03:39 -04:00
Lauren Hirata SinghandGitHub 15126ad827 Create overview.md 2025-05-23 22:02:14 -04:00
芥子观须弥andGitHub 6633173918 Fix: Ensure route function returns "b" to enable a → b → a loop (#4738)
fix: correct route function to return 'b' instead of 'a' when not terminating

This fixes a bug in the graph API how-to where the route function incorrectly returned 'a'. Now it correctly returns 'b' as intended.
2025-05-23 23:56:51 +00:00
Nuno Campos 0139e11ae5 0.4.7 2025-05-23 16:51:48 -07:00
Nuno CamposandGitHub bfbe55ab64 Fix stream mode not respected in subgraphs (#4806) 2025-05-23 16:47:01 -07:00
Nuno Campos 36478eb745 Fix stream mode not respected in subgraphs
- The default applied for subgraphs should be applied only when stream mode arg not passed in
2025-05-23 16:40:51 -07:00
Nuno CamposandGitHub 8fb91569b9 Add tests for stream_events when using imperative api (#4805) 2025-05-23 16:40:38 -07:00
Nuno Campos 9bf6728354 Try to make test less flaky 2025-05-23 16:32:51 -07:00
Nuno Campos 126a8f5bc6 Lock 2025-05-23 16:22:12 -07:00
Nuno Campos 9170f636d0 Lock 2025-05-23 16:14:15 -07:00
Nuno Campos 8207d3fefb Add tests for stream_events when using imperative api 2025-05-23 16:11:12 -07:00
Nuno Campos ade3f372a5 0.4.6 2025-05-23 15:24:07 -07:00
Nuno CamposandGitHub 3a55d1137b Fix exception handling for imperative tasks (#4802) 2025-05-23 15:23:15 -07:00
Nuno Campos bece43dc67 Fix 2025-05-23 15:06:18 -07:00
Nuno Campos 913b8d5e95 Lint 2025-05-23 14:32:45 -07:00
Andrew NguonlyandGitHub 29f6ea7f61 docs: Add warning about immutable deployment types (#4804)
* Add more details about database for deployment types. Clarify that deployment type cannot be changed.

* Add note about Development type disk capacity.
2025-05-23 14:04:46 -07:00
Nuno Campos 3c0d9346c2 Add sync test 2025-05-23 13:51:13 -07:00
Nuno Campos 0ebb78d9b2 Fix 2025-05-23 13:50:11 -07:00
Nuno Campos 70153ceba2 Fix exception handling for imperative tasks
- These exceptions should not be re-raised at end of tick, given they're handled explicitly by the developer in their entrypoint
2025-05-23 12:26:27 -07:00
Sydney RunkleandGitHub a9c87ed8b6 prebuilt: release 0.2.1 (#4801)
lockfile and version updates
2025-05-23 18:07:24 +00:00
Sydney RunkleandGitHub 837fe59e24 prebuilt: support provider builtin tools in create_react_agent (#4800) 2025-05-23 13:58:53 -04:00
Andrew NguonlyandGitHub 596a26461c docs: Add note about Enterprise plan in banner for all self-hosted deployment options. (#4797)
Add note about Enterprise plan in banner for all self-hosted deployment options.
2025-05-22 18:38:06 -07:00
Sydney RunkleandGitHub aff3be03dc prebuilt: release 0.2.0 (#4793) 2025-05-22 17:54:58 +00:00
David DuongandGitHub 1000b81eca feat(langgraph): push_messages should directly write to the state (#4791) 2025-05-22 19:41:51 +02:00
Tat Dat Duong 10c6ed7320 Code review 2025-05-22 19:35:44 +02:00
Tat Dat Duong 28e0e7f3ae feat(langgraph): push_messages should directly write to the state 2025-05-22 19:35:15 +02:00
Sydney RunkleandGitHub 87fa661ac0 temp: remove ToolInterruptNode code (#4792)
remove tool node specific code for post_model_hook release
2025-05-22 13:28:59 -04:00
David DuongandGitHub 3189b8fcda feat(sdk-js): Add rejoining of streams (#4744) 2025-05-22 19:19:07 +02:00
Tat Dat Duong 74b18acfc2 Bump to 0.0.78 2025-05-22 19:08:39 +02:00
Tat Dat Duong 1ff5e2966b Add joinStream method 2025-05-22 19:07:01 +02:00
Diwakar GuptaandGitHub 1ba2b3fba9 Merge branch 'main' into fix-examples-link-readme 2025-05-22 22:34:22 +05:30
Tat Dat Duong 102b1f63f3 Fix assumption on sessionStorage 2025-05-22 19:01:14 +02:00
Tat Dat Duong 74dbccd408 Rename to reconnectOnMount 2025-05-22 18:42:38 +02:00
Tat Dat Duong f5a2410ddc Make useStream more hackable 2025-05-22 18:38:09 +02:00
Sydney RunkleandGitHub 4cade428d8 docs: adding interactive graph widget (#4785) 2025-05-22 12:23:43 -04:00
Tat Dat Duong 5823a659fc use sessionStorage instead 2025-05-22 18:11:38 +02:00
Tat Dat Duong f2c32727e8 Fix invalid states 2025-05-22 18:09:16 +02:00
Tat Dat Duong 9d71d941fd resumable 2025-05-22 17:45:52 +02:00
Tat Dat Duong 0db618ae75 Further cleanup 2025-05-22 17:45:52 +02:00
Tat Dat Duong 49746ab3a3 Code cleanup 2025-05-22 17:45:51 +02:00
Tat Dat Duong 5c7ef9a4fc Fix race condition 2025-05-22 17:45:51 +02:00
Tat Dat Duong 62e688bb37 Add rejoining of streams 2025-05-22 17:45:51 +02:00
Sydney RunkleandGitHub 42eff39cd0 docs: fix top nav (#4783)
fix top nav
2025-05-21 19:30:13 -04:00
David DuongandGitHub 39172f2ff9 feat(graph): add push_message method to push manually to messages / message-tuple stream (#4722) 2025-05-22 01:01:48 +02:00
Nuno CamposandGitHub 0202c83e73 Apply same condition for stream_mode=values in suppress interrupt (#4782) 2025-05-21 15:55:55 -07:00
Tat Dat Duong ab924c72fa Add message state test 2025-05-22 00:49:23 +02:00
Nuno Campos 1833889316 Apply same condition for stream_mode=values in suppress interrupt 2025-05-21 15:23:27 -07:00
Tat Dat Duong 7a6bdb3441 Remove redundant cast 2025-05-22 00:23:26 +02:00
Tat Dat Duong d2875cc576 feat(graph): add push_message method to push manually to messages / message-tuple stream 2025-05-22 00:18:12 +02:00
Diwakar GuptaandGitHub 364fdf5dfe Merge branch 'main' into fix-examples-link-readme 2025-05-21 00:00:09 +05:30
Diwakar GuptaandGitHub fe5d303ccd Merge branch 'main' into fix-examples-link-readme 2025-05-17 13:56:41 +05:30
Diwakar GuptaandGitHub c9fa11ae7d Merge branch 'langchain-ai:main' into fix-examples-link-readme 2025-05-16 16:32:53 +05:30
Diw 988805d60b fix link to examples page in readme.md 2025-05-15 23:11:17 +05:30
60 changed files with 2703 additions and 2374 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ env:
jobs:
build:
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v0'
runs-on: ubuntu-latest
outputs:
+1 -1
View File
@@ -13,7 +13,7 @@ env:
jobs:
build:
if: github.ref == 'refs/heads/main'
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v0'
runs-on: ubuntu-latest
outputs:
+1 -1
View File
@@ -74,7 +74,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
- [Guides](https://langchain-ai.github.io/langgraph/how-tos/): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
- [Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
- [Examples](https://langchain-ai.github.io/langgraph/tutorials/): Guided examples on getting started with LangGraph.
- [Examples](https://langchain-ai.github.io/langgraph/tutorials/overview/): Guided examples on getting started with LangGraph.
- [LangChain Academy](https://academy.langchain.com/courses/intro-to-langgraph): Learn the basics of LangGraph in our free, structured course.
- [Templates](https://langchain-ai.github.io/langgraph/concepts/template_applications/): Pre-built reference apps for common agentic workflows (e.g. ReAct agent, memory, retrieval etc.) that can be cloned and adapted.
- [Case studies](https://www.langchain.com/built-with-langgraph): Hear how industry leaders use LangGraph to ship AI applications at scale.
+1 -1
View File
@@ -15,7 +15,7 @@ This guide shows you how to set up and use LangGraph's **prebuilt**, **reusable*
Before you start this tutorial, ensure you have the following:
- An [Anthropic](https://console.anthropic.com/settings/admin-keys) API key
- An [Anthropic](https://console.anthropic.com/settings/keys) API key
## 1. Install dependencies
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -82,7 +82,7 @@ ny_response = agent.invoke(
```
1. The `InMemorySaver` is a checkpointer that stores the agent's state in memory. In a production setting, you would typically use a database or other persistent storage. Please review the [checkpointer documentation](../reference/checkpoints.md) for more options. If you're deploying with **LangGraph Platform**, the platform will provide a production-ready checkpointer for you.
2. The `checkpointer` is passed to the agent. This enables the agent to persist its state across invocations. Please note that
2. The `checkpointer` is passed to the agent. This enables the agent to persist its state across invocations.
3. A unique `thread_id` is provided in the config. This ID is used to identify the conversation session. The value is controlled by the user and can be any string.
4. The agent will continue the conversation using the same `thread_id`. This will allow the agent to infer that the user is asking specifically about the **weather** in New York.
+136
View File
@@ -53,3 +53,139 @@ The high-level components are organized into several packages, each with a speci
| `langmem` | Agent memory management: [**short-term and long-term**](./memory.md) | `pip install -U langmem` |
| `agentevals` | Utilities to [**evaluate agent performance**](./evals.md) | `pip install -U agentevals` |
## Visualize an agent graph
Use the following tool to visualize the graph generated by
[`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent]
and to view an outline of the corresponding code.
It allows you to explore the infrastructure of the agent as defined by the presence of:
* [`tools`](../agents/tools.md): A list of tools (functions, APIs, or other callable objects) that the agent can use to perform tasks.
* [`pre_model_hook`](../how-tos/create-react-agent-manage-message-history.ipynb): A function that is called before the model is invoked. It can be used to condense messages or perform other preprocessing tasks.
* `post_model_hook`: A function that is called after the model is invoked. It can be used to implement guardrails, human-in-the-loop flows, or other postprocessing tasks.
* [`response_format`](../agents/agents.md#6-configure-structured-output): A data structure used to constrain the type of the final output, e.g., a `pydantic` `BaseModel`.
<div class="agent-layout">
<div class="agent-graph-features-container">
<div class="agent-graph-features">
<h3 class="agent-section-title">Features</h3>
<label><input type="checkbox" id="tools" checked> <code>tools</code></label>
<label><input type="checkbox" id="pre_model_hook"> <code>pre_model_hook</code></label>
<label><input type="checkbox" id="post_model_hook"> <code>post_model_hook</code></label>
<label><input type="checkbox" id="response_format"> <code>response_format</code></label>
</div>
</div>
<div class="agent-graph-container">
<h3 class="agent-section-title">Graph</h3>
<img id="agent-graph-img" src="../assets/react_agent_graphs/0001.svg" alt="graph image" style="max-width: 100%;"/>
</div>
</div>
The following code snippet shows how to create the above agent (and underlying graph) with
[`create_react_agent`][langgraph.prebuilt.chat_agent_executor.create_react_agent]:
<div class="language-python">
<pre><code id="agent-code" class="language-python"></code></pre>
</div>
<script>
function getCheckedValue(id) {
return document.getElementById(id).checked ? "1" : "0";
}
function getKey() {
return [
getCheckedValue("response_format"),
getCheckedValue("post_model_hook"),
getCheckedValue("pre_model_hook"),
getCheckedValue("tools")
].join("");
}
function generateCodeSnippet({ tools, pre, post, response }) {
const lines = [
"from langgraph.prebuilt import create_react_agent",
"from langchain_openai import ChatOpenAI"
];
if (response) lines.push("from pydantic import BaseModel");
lines.push("", 'model = ChatOpenAI("o4-mini")', "");
if (tools) {
lines.push(
"def tool() -> None:",
' """Testing tool."""',
" ...",
""
);
}
if (pre) {
lines.push(
"def pre_model_hook() -> None:",
' """Pre-model hook."""',
" ...",
""
);
}
if (post) {
lines.push(
"def post_model_hook() -> None:",
' """Post-model hook."""',
" ...",
""
);
}
if (response) {
lines.push(
"class ResponseFormat(BaseModel):",
' """Response format for the agent."""',
" result: str",
""
);
}
lines.push("agent = create_react_agent(");
lines.push(" model,");
if (tools) lines.push(" tools=[tool],");
if (pre) lines.push(" pre_model_hook=pre_model_hook,");
if (post) lines.push(" post_model_hook=post_model_hook,");
if (response) lines.push(" response_format=ResponseFormat,");
lines.push(")", "", "agent.get_graph().draw_mermaid_png()");
return lines.join("\n");
}
async function render() {
const key = getKey();
document.getElementById("agent-graph-img").src = `../assets/react_agent_graphs/${key}.svg`;
const state = {
tools: document.getElementById("tools").checked,
pre: document.getElementById("pre_model_hook").checked,
post: document.getElementById("post_model_hook").checked,
response: document.getElementById("response_format").checked
};
document.getElementById("agent-code").textContent = generateCodeSnippet(state);
}
function initializeWidget() {
render(); // no need for `await` here
document.querySelectorAll(".agent-graph-features input").forEach((input) => {
input.addEventListener("change", render);
});
}
// Init for both full reload and SPA nav (used by MkDocs Material)
window.addEventListener("DOMContentLoaded", initializeWidget);
document$.subscribe(initializeWidget);
</script>
+15 -1
View File
@@ -280,7 +280,21 @@ LangGraph allows access to short-term and long-term memory from tools. See [Memo
## Prebuilt tools
LangChain supports a wide range of prebuilt tool integrations for interacting with APIs, databases, file systems, web data, and more. These tools extend the functionality of agents and enable rapid development.
You can use prebuilt tools from model providers by passing a dictionary with tool specs to the `tools` parameter of `create_react_agent`. For example, to use the `web_search_preview` tool from OpenAI:
```python
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(
model="openai:gpt-4o-mini",
tools=[{"type": "web_search_preview"}]
)
response = agent.invoke(
{"messages": ["What was a positive news story from today?"]}
)
```
Additionally, LangChain supports a wide range of prebuilt tool integrations for interacting with APIs, databases, file systems, web data, and more. These tools extend the functionality of agents and enable rapid development.
You can browse the full list of available integrations in the [LangChain integrations directory](https://python.langchain.com/docs/integrations/tools/).
+19
View File
@@ -0,0 +1,19 @@
{
"0000": "graph TD;\n\t__start__ --> agent;\n\tagent --> __end__;",
"0001": "graph TD;\n\t__start__ --> agent;\n\tagent -.-> __end__;\n\tagent -.-> tools;\n\ttools --> agent;",
"0010": "graph TD;\n\t__start__ --> pre_model_hook;\n\tpre_model_hook --> agent;\n\tagent --> __end__;",
"0011": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent -.-> __end__;\n\tagent -.-> tools;\n\tpre_model_hook --> agent;\n\ttools --> pre_model_hook;",
"0100": "graph TD;\n\t__start__ --> agent;\n\tagent --> post_model_hook;\n\tpost_model_hook --> __end__;",
"0101": "graph TD;\n\t__start__ --> agent;\n\tagent --> post_model_hook;\n\tpost_model_hook -.-> __end__;\n\tpost_model_hook -.-> agent;\n\tpost_model_hook -.-> tools;\n\ttools --> agent;",
"0110": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent --> post_model_hook;\n\tpre_model_hook --> agent;\n\tpost_model_hook --> __end__;",
"0111": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent --> post_model_hook;\n\tpost_model_hook -.-> __end__;\n\tpost_model_hook -.-> pre_model_hook;\n\tpost_model_hook -.-> tools;\n\tpre_model_hook --> agent;\n\ttools --> pre_model_hook;",
"1000": "graph TD;\n\t__start__ --> agent;\n\tagent --> generate_structured_response;\n\tgenerate_structured_response --> __end__;",
"1001": "graph TD;\n\t__start__ --> agent;\n\tagent -.-> generate_structured_response;\n\tagent -.-> tools;\n\ttools --> agent;\n\tgenerate_structured_response --> __end__;",
"1010": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent --> generate_structured_response;\n\tpre_model_hook --> agent;\n\tgenerate_structured_response --> __end__;",
"1011": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent -.-> generate_structured_response;\n\tagent -.-> tools;\n\tpre_model_hook --> agent;\n\ttools --> pre_model_hook;\n\tgenerate_structured_response --> __end__;",
"1100": "graph TD;\n\t__start__ --> agent;\n\tagent --> post_model_hook;\n\tpost_model_hook --> generate_structured_response;\n\tgenerate_structured_response --> __end__;",
"1101": "graph TD;\n\t__start__ --> agent;\n\tagent --> post_model_hook;\n\tpost_model_hook -.-> agent;\n\tpost_model_hook -.-> generate_structured_response;\n\tpost_model_hook -.-> tools;\n\ttools --> agent;\n\tgenerate_structured_response --> __end__;",
"1110": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent --> post_model_hook;\n\tpost_model_hook --> generate_structured_response;\n\tpre_model_hook --> agent;\n\tgenerate_structured_response --> __end__;",
"1111": "graph TD;\n\t__start__ --> pre_model_hook;\n\tagent --> post_model_hook;\n\tpost_model_hook -.-> generate_structured_response;\n\tpost_model_hook -.-> pre_model_hook;\n\tpost_model_hook -.-> tools;\n\tpre_model_hook --> agent;\n\ttools --> pre_model_hook;\n\tgenerate_structured_response --> __end__;"
}
@@ -2,8 +2,8 @@
Before deploying, review the [conceptual guide for the Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployment option.
!!! important "Beta"
The Self-Hosted Control Plane deployment option is currently in beta stage.
!!! info "Important"
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
## Prerequisites
@@ -2,8 +2,8 @@
Before deploying, review the [conceptual guide for the Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) deployment option.
!!! important "Beta"
The Self-Hosted Data Plane deployment option is currently in beta stage.
!!! info "Important"
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
## Prerequisites
+4 -4
View File
@@ -40,8 +40,8 @@ For more information, please see:
## Self-Hosted Data Plane
!!! important "Beta"
The Self-Hosted Data Plane deployment option is currently in beta stage.
!!! info "Important"
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../concepts/plans.md) plan.
The [Self-Hosted Data Plane](./langgraph_self_hosted_data_plane.md) deployment option is a "hybrid" model for deployment where we manage the [control plane](./langgraph_control_plane.md) in our cloud and you manage the [data plane](./langgraph_data_plane.md) in your cloud. This option provides a way to securely manage your data plane infrastructure, while offloading control plane management to us.
@@ -56,8 +56,8 @@ For more information, please see:
## Self-Hosted Control Plane
!!! important "Beta"
The Self-Hosted Control Plane deployment option is currently in beta stage.
!!! info "Important"
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../concepts/plans.md) plan.
The [Self-Hosted Control Plane](./langgraph_self_hosted_control_plane.md) deployment option is a fully self-hosted model for deployment where you manage the [control plane](./langgraph_control_plane.md) and [data plane](./langgraph_data_plane.md) in your cloud. This option give you full control and responsibility of the control plane and data plane infrastructure.
+11 -6
View File
@@ -47,17 +47,22 @@ This section describes various features of the control plane.
For simplicity, the control plane offers two deployment types with different resource allocations: `Development` and `Production`.
| **Deployment Type** | **CPU** | **Memory** | **Scaling** |
|---------------------|---------|------------|---------------------|
| Development | 1 CPU | 1 GB | Up to 1 container |
| Production | 2 CPU | 2 GB | Up to 10 containers |
| **Deployment Type** | **CPU/Memory** | **Scaling** | **Database** |
|---------------------|-----------------|---------------------|----------------------------------------------------------------------------------|
| Development | 1 CPU, 1 GB RAM | Up to 1 container | 10 GB disk, no backups |
| Production | 2 CPU, 2 GB RAM | Up to 10 containers | Autoscaling disk, automatic backups, highly available (multi-zone configuration) |
CPU and memory resources are per container.
!!! info "For [Cloud SaaS](../concepts/langgraph_cloud.md)"
!!! warning "Immutable Deployment Type"
Once a deployment is created, the deployment type cannot be changed.
!!! info "Resource Customization"
For `Production` type deployments, resources can be manually increased on a case-by-case basis depending on use case and capacity constraints. Contact support@langchain.dev to request an increase in resources.
!!! info
For `Development` types deployments, database disk size can be manually increased on a case-by-case basis depending on use case and capacity constraints. For most use cases, [TTLs](../how-tos/ttl/configure_ttl.md) should be configured to manage disk usage. Contact support@langchain.dev to request an increase in resources.
Resources for [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md) deployments can be fully customized.
### Database Provisioning
@@ -2,8 +2,8 @@
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
!!! important "Beta"
The Self-Hosted Control Plane deployment option is currently in beta stage.
!!! info "Important"
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
## Requirements
@@ -7,8 +7,8 @@ search:
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
!!! important "Beta"
The Self-Hosted Data Plane deployment option is currently in beta stage.
!!! info "Important"
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
## Requirements
+1 -11
View File
@@ -2235,7 +2235,7 @@
" if termination_condition(state):\n",
" return END\n",
" else:\n",
" return \"a\"\n",
" return \"b\"\n",
"\n",
"builder.add_edge(START, \"a\")\n",
"builder.add_conditional_edges(\"a\", route)\n",
@@ -2950,16 +2950,6 @@
" When you send updates from a subgraph node to a parent graph node for a key that's shared by both parent and subgraph [state schemas](../../concepts/low_level#schema), you **must** define a [reducer](../../concepts/low_level#reducers) for the key you're updating in the parent graph state. See the example below."
]
},
{
"cell_type": "markdown",
"id": "6be0aeb9-e138-4adc-a1df-5d743a8eb348",
"metadata": {},
"source": [
"!!! important \"State updates with `Command.PARENT`\"\n",
"\n",
" When you send updates from a subgraph node to a parent graph node for a key that's shared by both parent and subgraph [state schemas](../../concepts/low_level#schema), you **must** define a [reducer](../../concepts/low_level#reducers) for the key you're updating in the parent graph state."
]
},
{
"cell_type": "code",
"execution_count": 6,
@@ -0,0 +1,57 @@
.agent-layout {
display: flex;
flex-wrap: nowrap;
gap: 1rem;
align-items: flex-start;
margin-top: 1rem;
}
.agent-layout h3 {
margin: 0;
}
.agent-graph-features {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
max-width: 300px;
flex-shrink: 0;
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.5rem;
background-color: var(--md-default-bg-color);
}
.agent-graph-features label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--md-typeset-color);
cursor: pointer;
}
.agent-graph-features input[type="checkbox"] {
accent-color: var(--md-accent-fg-color);
transform: scale(1.2);
}
.agent-graph-container {
flex: 1 1 50%;
max-width: 70%;
padding: 1rem;
overflow: auto;
height: auto;
box-sizing: border-box;
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.5rem;
background-color: var(--md-default-bg-color);
}
.agent-graph-container img {
display: block;
margin: 0 auto;
}
@@ -6,7 +6,7 @@ In this tutorial, you will build a basic chatbot. This chatbot is the basis for
Before you start this tutorial, ensure you have access to a LLM that supports
tool-calling features, such as [OpenAI](https://platform.openai.com/api-keys),
[Anthropic](https://console.anthropic.com/settings/admin-keys), or
[Anthropic](https://console.anthropic.com/settings/keys), or
[Google Gemini](https://ai.google.dev/gemini-api/docs/api-key).
## 1. Install packages
@@ -146,7 +146,7 @@ graph_builder.add_node("tools", tool_node)
!!! note
If you do not want to build this yourself in the future, you can use LangGraph's prebuilt [ToolNode](https://langchain-ai.github.io/langgraph/reference/prebuilt/#toolnode).
If you do not want to build this yourself in the future, you can use LangGraph's prebuilt [ToolNode](https://langchain-ai.github.io/langgraph/reference/agents/#langgraph.prebuilt.tool_node.ToolNode).
## 6. Define the `conditional_edges`
+21
View File
@@ -0,0 +1,21 @@
# Examples
The pages in this section provide end-to-end examples for the following topics:
## General
- [Agentic RAG](./rag/langgraph_adaptive_rag.ipynb)
- [Agent Supervisor](./multi_agent/agent_supervisor.ipynb)
- [SQL agent](./sql-agent.ipynb)
- [Graph runs in LangSmith](../how-tos/run-id-langsmith.ipynb)
## LangGraph Platform
- [Set up custom authentication](./auth/getting_started.md)
- [Make conversations private](./auth/resource_auth.md)
- [Connect an authentication provider](./auth/add_auth_server.md)
- [Rebuild graph at runtime](../cloud/deployment/graph_rebuild.md)
- [Use RemoteGraph](../how-tos/use-remote-graph.md)
- [Deploy CrewAI, AutoGen, and other frameworks](../how-tos/autogen-langgraph-platform.ipynb)
- [Integrate LangGraph into a React app](../cloud/how-tos/use_stream_react.md)
- [Implement Generative User Interfaces with LangGraph](../cloud/how-tos/generative_ui_react.md)
+2 -2
View File
@@ -19,7 +19,6 @@ theme:
- content.tabs.link
- content.action.edit
- content.tooltips
- header.autohide
- navigation.indexes
- navigation.footer
- navigation.instant
@@ -28,7 +27,6 @@ theme:
- navigation.instant.progress
- navigation.path
- navigation.tabs
- navigation.tabs.sticky
- navigation.top
- navigation.prune
- navigation.tracking
@@ -418,3 +416,5 @@ extra_css:
- stylesheets/version_admonitions.css
- stylesheets/logos.css
- stylesheets/sticky_navigation.css
- stylesheets/agent_graph_widget.css
Generated
+6 -5
View File
@@ -2590,7 +2590,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "0.4.5"
version = "0.4.7"
source = { editable = "../libs/langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -2891,7 +2891,7 @@ test = [
[[package]]
name = "langgraph-prebuilt"
version = "0.1.8"
version = "0.2.2"
source = { editable = "../libs/prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -2917,11 +2917,12 @@ dev = [
{ name = "pytest-mock" },
{ name = "pytest-watcher" },
{ name = "ruff" },
{ name = "syrupy" },
]
[[package]]
name = "langgraph-sdk"
version = "0.1.69"
version = "0.1.70"
source = { editable = "../libs/sdk-py" }
dependencies = [
{ name = "httpx" },
@@ -2947,8 +2948,8 @@ dev = [
[[package]]
name = "langgraph-supervisor"
version = "0.0.21"
source = { git = "https://github.com/langchain-ai/langgraph-supervisor-py#6367bebd5462ac899e7def931ac6ab9cc6a9b070" }
version = "0.0.25"
source = { git = "https://github.com/langchain-ai/langgraph-supervisor-py#79380b5c21d3170e2d20dc6c55149ee057a306b1" }
dependencies = [
{ name = "langchain-core" },
{ name = "langgraph" },
+1 -1
View File
@@ -74,7 +74,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
- [Guides](https://langchain-ai.github.io/langgraph/how-tos/): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
- [Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
- [Examples](https://langchain-ai.github.io/langgraph/tutorials/): Guided examples on getting started with LangGraph.
- [Examples](https://langchain-ai.github.io/langgraph/tutorials/overview/): Guided examples on getting started with LangGraph.
- [LangChain Academy](https://academy.langchain.com/courses/intro-to-langgraph): Learn the basics of LangGraph in our free, structured course.
- [Templates](https://langchain-ai.github.io/langgraph/concepts/template_applications/): Pre-built reference apps for common agentic workflows (e.g. ReAct agent, memory, retrieval etc.) that can be cloned and adapted.
- [Case studies](https://www.langchain.com/built-with-langgraph): Hear how industry leaders use LangGraph to ship AI applications at scale.
+50
View File
@@ -23,6 +23,7 @@ from langchain_core.messages import (
)
from typing_extensions import TypedDict
from langgraph.constants import CONF, CONFIG_KEY_SEND
from langgraph.graph.state import StateGraph
Messages = Union[list[MessageLikeRepresentation], MessageLikeRepresentation]
@@ -294,3 +295,52 @@ def _format_messages(messages: Sequence[BaseMessage]) -> list[BaseMessage]:
return list(messages)
else:
return convert_to_messages(convert_to_openai_messages(messages))
def push_message(
message: Union[MessageLikeRepresentation, BaseMessageChunk],
*,
state_key: Optional[str] = "messages",
) -> AnyMessage:
"""Write a message manually to the `messages` / `messages-tuple` stream mode.
Will automatically write to the channel specified in the `state_key` unless `state_key` is `None`.
"""
from langchain_core.callbacks.base import (
BaseCallbackHandler,
BaseCallbackManager,
)
from langgraph.config import get_config
from langgraph.constants import NS_SEP
from langgraph.pregel.messages import StreamMessagesHandler
config = get_config()
message = next(x for x in convert_to_messages([message]))
if message.id is None:
raise ValueError("Message ID is required")
if isinstance(config["callbacks"], BaseCallbackManager):
manager = config["callbacks"]
handlers = manager.handlers
elif isinstance(config["callbacks"], list) and all(
isinstance(x, BaseCallbackHandler) for x in config["callbacks"]
):
handlers = config["callbacks"]
if stream_handler := next(
(x for x in handlers if isinstance(x, StreamMessagesHandler)), None
):
metadata = config["metadata"]
message_meta = (
tuple(cast(str, metadata["langgraph_checkpoint_ns"]).split(NS_SEP)),
metadata,
)
stream_handler._emit(message_meta, message, dedupe=False)
if state_key:
config[CONF][CONFIG_KEY_SEND]([(state_key, message)])
return message
@@ -1,268 +0,0 @@
import functools
import logging
import weakref
from dataclasses import is_dataclass
from inspect import isclass
from typing import (
Annotated,
Any,
Callable,
Optional,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel, ConfigDict, TypeAdapter
from typing_extensions import is_typeddict
__all__ = ["SchemaCoercionMapper"]
logger = logging.getLogger(__name__)
_cache: weakref.WeakKeyDictionary[type[Any], dict[int, "SchemaCoercionMapper"]] = (
weakref.WeakKeyDictionary()
)
class SchemaCoercionMapper:
"""Lightweight coercion of *dict* → *BaseModel* instances."""
def __new__(
cls,
schema: type[Any],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
) -> "SchemaCoercionMapper":
by_depth = _cache.setdefault(schema, {})
if max_depth in by_depth:
return by_depth[max_depth]
inst = super().__new__(cls)
by_depth[max_depth] = inst
return inst
def __init__(
self,
schema: type[BaseModel],
type_hints: Optional[dict[str, Any]] = None,
*,
max_depth: int = 12,
) -> None:
if hasattr(self, "_initialised"):
return
self._initialised = True
self.schema = schema
self.max_depth = max_depth
self.type_hints = (
type_hints
if type_hints is not None
else get_type_hints(schema, localns={schema.__name__: schema})
)
if issubclass(schema, BaseModel):
self._fields = {
n: self.type_hints.get(n, f.annotation)
for n, f in schema.model_fields.items()
}
self._construct: Callable[..., Any] = schema.model_construct
unhandled_attrs = ("validators", "field_validators", "root_validators")
if (decorators := getattr(schema, "__pydantic_decorators__", None)) and any(
getattr(decorators, attr, None) for attr in unhandled_attrs
):
self.coerce = lambda v, _: schema.model_validate(v)
else:
self.coerce = self._coerce
else:
raise TypeError("Schema must be a Pydantic V2 model.")
self._field_coercers: Optional[dict[str, Callable[[Any, int], Any]]] = None
def __call__(self, input_data: Any, depth: Optional[int] = None) -> Any:
return self.coerce(input_data, depth)
def _coerce(self, input_data: Any, depth: Optional[int] = None) -> Any:
if depth is None:
depth = self.max_depth
if not isinstance(input_data, dict) or depth <= 0:
return input_data
if self._field_coercers is None:
self._field_coercers = {
n: self._build_coercer(t, depth - 1) for n, t in self._fields.items()
}
processed: dict[str, Any] = {}
for k, v in input_data.items():
fn = self._field_coercers.get(k)
processed[k] = fn(v, depth - 1) if fn else v
return self._construct(**processed)
def _build_coercer(
self, field_type: Any, depth: int, *, throw: bool = False
) -> Callable[[Any, Any], Any]:
if depth == 0:
return self._passthrough
origin = get_origin(field_type)
if (field_type in _IDENTITY_TYPES) or (origin in _IDENTITY_TYPES):
return self._passthrough
if origin is Annotated:
real_type, *_ = get_args(field_type)
sub = self._build_coercer(real_type, depth - 1)
return lambda v, d: sub(v, d)
if isclass(field_type):
# This is needed bcs. of issubclass issues on older versions of python
try:
is_bm_subclass = issubclass(field_type, BaseModel)
except TypeError:
# python < 3.11 issue.
is_bm_subclass = False
if is_bm_subclass:
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
if origin is list:
args = get_args(field_type)
if len(args) != 1:
return self._passthrough
sub = self._build_coercer(args[0], depth - 1)
def list_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple)):
return v
return [sub(x, d - 1) for x in v]
return list_coercer
if origin is set or field_type is set:
args = get_args(field_type)
if len(args) > 1:
return self._passthrough
elif len(args) == 1:
sub = self._build_coercer(args[0], depth - 1)
else:
sub = None # type: ignore
def set_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, (list, tuple, set)):
return v
if sub is None:
return set(v)
return {sub(x, d - 1) for x in v}
return set_coercer
if origin is dict or field_type is dict:
args = get_args(field_type)
if len(args) != 2:
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError(f"Expected dict, got {type(v)}")
return v
return dict_coercer
k_sub = self._build_coercer(args[0], depth - 1)
v_sub = self._build_coercer(args[1], depth - 1)
def dict_coercer(v: Any, d: Any) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError(f"Expected dict, got {type(v)}")
return v
return {k_sub(k, d - 1): v_sub(val, d - 1) for k, val in v.items()}
return dict_coercer
if origin is tuple:
elem_types = get_args(field_type)
if not elem_types:
return self._passthrough
subs = [self._build_coercer(t, depth - 1) for t in elem_types]
return lambda v, d: (
tuple(
subs[i](v[i] if i < len(v) else None, d - 1)
for i in range(len(subs))
)
if isinstance(v, (list, tuple))
else v
)
if origin is Union:
uargs = get_args(field_type)
subs, none_in_union = [], False
for ix, arg in enumerate(uargs):
if arg is type(None):
none_in_union = True
else:
subs.append(
self._build_coercer(arg, depth - 1, throw=ix < len(uargs) - 1)
)
def union_coercer(v: Any, d: Any) -> Any:
if v is None and none_in_union:
return None
err = None
for sp in subs:
try:
return sp(v, d - 1)
except TypeError as e:
err = e
if err:
raise err
return v
return union_coercer
adapter_fn = _get_adapter(field_type)
return lambda v, _d: adapter_fn(v)
@staticmethod
def _passthrough(v: Any, _d: Any) -> Any: # noqa: D401
return v
_adapter_cache: dict[Any, Callable[[Any], Any]] = {}
_IDENTITY_TYPES: tuple[type[Any], ...] = (
int,
float,
str,
bool,
bytes,
bytearray,
complex,
memoryview,
type(None),
)
@functools.lru_cache(maxsize=2048)
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
try:
config = (
None
if (issubclass(tp, BaseModel) or is_dataclass(tp) or is_typeddict(tp))
else ConfigDict(arbitrary_types_allowed=True)
)
except TypeError:
config = None
return TypeAdapter(tp, config=config).validate_python
def _get_adapter(tp: Any) -> Callable[[Any], Any]:
try:
return _adapter_cache[tp]
except KeyError:
fn = _adapter_for(tp)
_adapter_cache[tp] = fn
return fn
+2 -6
View File
@@ -64,7 +64,6 @@ from langgraph.graph.graph import (
Graph,
Send,
)
from langgraph.graph.schema_utils import SchemaCoercionMapper
from langgraph.managed.base import (
ChannelKeyPlaceholder,
ChannelTypePlaceholder,
@@ -1042,11 +1041,8 @@ def _pick_mapper(
) -> Optional[Callable[[Any], Any]]:
if state_keys == ["__root__"]:
return None
if isclass(schema):
if issubclass(schema, dict):
return None
if issubclass(schema, BaseModel):
return SchemaCoercionMapper(schema, type_hints=type_hints)
if isclass(schema) and issubclass(schema, dict):
return None
return partial(_coerce_state, schema)
+3 -2
View File
@@ -54,7 +54,7 @@ def push_ui_message(
id: Optional[str] = None,
metadata: Optional[dict[str, Any]] = None,
message: Optional[AnyMessage] = None,
state_key: str = "ui",
state_key: Optional[str] = "ui",
merge: bool = False,
) -> UIMessage:
"""Push a new UI message to update the UI state.
@@ -111,7 +111,8 @@ def push_ui_message(
}
writer(evt)
config[CONF][CONFIG_KEY_SEND]([(state_key, evt)])
if state_key:
config[CONF][CONFIG_KEY_SEND]([(state_key, evt)])
return evt
+6 -4
View File
@@ -2214,12 +2214,14 @@ class Pregel(PregelProtocol):
validate_keys(output_keys, self.channels)
interrupt_before = interrupt_before or self.interrupt_before_nodes
interrupt_after = interrupt_after or self.interrupt_after_nodes
stream_mode = stream_mode if stream_mode is not None else self.stream_mode
if stream_mode is None and CONFIG_KEY_TASK_ID in config.get(CONF, {}):
# if being called as a node in another graph, default to values mode
# but don't overwrite stream_mode arg if provided
stream_mode = ["values"]
elif stream_mode is None:
stream_mode = self.stream_mode
if not isinstance(stream_mode, list):
stream_mode = [stream_mode]
if CONFIG_KEY_TASK_ID in config.get(CONF, {}):
# if being called as a node in another graph, always use values mode
stream_mode = ["values"]
if self.checkpointer is False:
checkpointer: BaseCheckpointSaver | None = None
elif CONFIG_KEY_CHECKPOINTER in config.get(CONF, {}):
+13 -9
View File
@@ -135,7 +135,6 @@ P = ParamSpec("P")
INPUT_DONE = object()
INPUT_RESUMING = object()
INPUT_SHOULD_VALIDATE = object()
SPECIAL_CHANNELS = (ERROR, INTERRUPT, SCHEDULED)
WritesT = Sequence[tuple[str, Any]]
@@ -892,7 +891,7 @@ class PregelLoop(LoopProtocol):
and self.checkpoint_pending_writes
and any(task.writes for task in self.tasks.values())
):
mv_writes, _ = apply_writes(
mv_writes, updated_channels = apply_writes(
self.checkpoint,
self.channels,
self.tasks.values(),
@@ -901,13 +900,18 @@ class PregelLoop(LoopProtocol):
)
for key, values in mv_writes.items():
self._update_mv(key, values)
self._emit(
"values",
map_output_values,
self.output_keys,
[w for t in self.tasks.values() for w in t.writes],
self.channels,
)
if not updated_channels.isdisjoint(
(self.output_keys,)
if isinstance(self.output_keys, str)
else self.output_keys
):
self._emit(
"values",
map_output_values,
self.output_keys,
[w for t in self.tasks.values() for w in t.writes],
self.channels,
)
# emit INTERRUPT if exception is empty (otherwise emitted by put_writes)
if exc_value is not None and (not exc_value.args or not exc_value.args[0]):
self._emit(
+2 -2
View File
@@ -41,7 +41,7 @@ def run_with_retry(
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
if cmd.graph == ns:
if cmd.graph in (ns, task.name):
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
@@ -137,7 +137,7 @@ async def arun_with_retry(
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
cmd = exc.args[0]
if cmd.graph == ns:
if cmd.graph in (ns, task.name):
# this command is for the current graph, handle it
for w in task.writers:
w.invoke(cmd, config)
+19 -17
View File
@@ -56,6 +56,10 @@ EXCLUDED_FRAME_FNAMES = (
"concurrent/futures/_base.py",
)
SKIP_RERAISE_SET: weakref.WeakSet[Union[concurrent.futures.Future, asyncio.Future]] = (
weakref.WeakSet()
)
class FuturesDict(Generic[F, E], dict[F, Optional[PregelExecutableTask]]):
event: E
@@ -165,7 +169,6 @@ class PregelRunner:
futures=weakref.ref(futures),
schedule_task=schedule_task,
submit=self.submit,
reraise=reraise,
),
},
)
@@ -207,7 +210,6 @@ class PregelRunner:
futures=weakref.ref(futures),
schedule_task=schedule_task,
submit=self.submit,
reraise=reraise,
),
},
__reraise_on_exit__=reraise,
@@ -302,7 +304,6 @@ class PregelRunner:
futures=weakref.ref(futures),
schedule_task=schedule_task,
submit=self.submit,
reraise=reraise,
loop=loop,
),
},
@@ -349,7 +350,6 @@ class PregelRunner:
futures=weakref.ref(futures),
schedule_task=schedule_task,
submit=self.submit,
reraise=reraise,
loop=loop,
),
},
@@ -434,7 +434,8 @@ class PregelRunner:
raise exception
else:
# save error to checkpointer
self.put_writes()(task.id, [(ERROR, exception)]) # type: ignore[misc]
task.writes.append((ERROR, exception))
self.put_writes()(task.id, task.writes) # type: ignore[misc]
else:
if self.node_finished and (
task.config is None or TAG_HIDDEN not in task.config.get("tags", [])
@@ -456,7 +457,7 @@ def _should_stop_others(
if fut.cancelled():
continue
elif exc := fut.exception():
if not isinstance(exc, GraphBubbleUp):
if not isinstance(exc, GraphBubbleUp) and fut not in SKIP_RERAISE_SET:
return True
return False
@@ -494,7 +495,8 @@ def _panic_or_proceed(
interrupts: list[GraphInterrupt] = []
while done:
# if any task failed
if exc := _exception(done.pop()):
fut = done.pop()
if exc := _exception(fut):
# cancel all pending tasks
while inflight:
inflight.pop().cancel()
@@ -503,7 +505,7 @@ def _panic_or_proceed(
if isinstance(exc, GraphInterrupt):
# collect interrupts
interrupts.append(exc)
else:
elif fut not in SKIP_RERAISE_SET:
raise exc
# raise combined interrupts
if interrupts:
@@ -530,7 +532,6 @@ def _call(
[PregelExecutableTask, int, Optional[Call]], Optional[PregelExecutableTask]
],
submit: weakref.ref[Submit],
reraise: bool,
) -> concurrent.futures.Future[Any]:
if asyncio.iscoroutinefunction(func):
raise RuntimeError("In an sync context async tasks cannot be called")
@@ -582,14 +583,16 @@ def _call(
callbacks=callbacks,
schedule_task=schedule_task,
submit=submit,
reraise=reraise,
),
},
__reraise_on_exit__=reraise,
__reraise_on_exit__=False,
# starting a new task in the next tick ensures
# updates from this tick are committed/streamed first
__next_tick__=True,
)
# exceptions for call() tasks are raised into the parent task
# so we should not re-raise at the end of the tick
SKIP_RERAISE_SET.add(fut)
futures()[fut] = next_task # type: ignore[index]
fut = cast(Union[asyncio.Future, concurrent.futures.Future], fut)
# return a chained future to ensure commit() callback is called
@@ -613,7 +616,6 @@ def _acall(
],
submit: weakref.ref[Submit],
loop: asyncio.AbstractEventLoop,
reraise: bool = False,
stream: bool = False,
) -> Union[asyncio.Future[Any], concurrent.futures.Future[Any]]:
# return a chained future to ensure commit() callback is called
@@ -643,7 +645,6 @@ def _acall(
schedule_task=schedule_task,
submit=submit,
loop=loop,
reraise=reraise,
stream=stream,
),
loop,
@@ -669,7 +670,6 @@ async def _acall_impl(
],
submit: weakref.ref[Submit],
loop: asyncio.AbstractEventLoop,
reraise: bool = False,
stream: bool = False,
) -> None:
try:
@@ -726,17 +726,19 @@ async def _acall_impl(
schedule_task=schedule_task,
submit=submit,
loop=loop,
reraise=reraise,
),
},
__name__=task().name, # type: ignore[union-attr]
__name__=next_task.name,
__cancel_on_exit__=True,
__reraise_on_exit__=reraise,
__reraise_on_exit__=False,
# starting a new task in the next tick ensures
# updates from this tick are committed/streamed first
__next_tick__=True,
),
)
# exceptions for call() tasks are raised into the parent task
# so we should not re-raise at the end of the tick
SKIP_RERAISE_SET.add(fut)
futures()[fut] = next_task # type: ignore[index]
if fut is not None:
chain_future(fut, destination)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "0.4.5"
version = "0.4.8"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.9"
@@ -15,7 +15,7 @@ dependencies = [
"langchain-core>=0.1",
"langgraph-checkpoint>=2.0.26",
"langgraph-sdk>=0.1.42",
"langgraph-prebuilt>=0.1.8",
"langgraph-prebuilt>=0.2.0",
"xxhash>=3.5.0",
"pydantic>=2.7.4",
]
+32 -1
View File
@@ -15,7 +15,7 @@ from pydantic import BaseModel
from typing_extensions import TypedDict
from langgraph.graph import add_messages
from langgraph.graph.message import REMOVE_ALL_MESSAGES, MessagesState
from langgraph.graph.message import REMOVE_ALL_MESSAGES, MessagesState, push_message
from langgraph.graph.state import END, START, StateGraph
from tests.messages import _AnyIdHumanMessage
@@ -332,3 +332,34 @@ def test_remove_all_messages():
assert result == [
_AnyIdHumanMessage(content="Updated hi there"),
]
def test_push_messages_in_graph():
class MessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
def chat(_: MessagesState) -> MessagesState:
with pytest.raises(ValueError, match="Message ID is required"):
push_message(AIMessage(content="No ID"))
push_message(AIMessage(content="First", id="1"))
push_message(HumanMessage(content="Second", id="2"))
push_message(AIMessage(content="Third", id="3"))
builder = StateGraph(MessagesState)
builder.add_node(chat)
builder.add_edge(START, "chat")
graph = builder.compile()
messages, values = [], None
for event, chunk in graph.stream(
{"messages": []}, stream_mode=["messages", "values"]
):
if event == "values":
values = chunk
elif event == "messages":
message, _ = chunk
messages.append(message)
assert values["messages"] == messages
+79 -3
View File
@@ -5514,8 +5514,11 @@ def test_runnable_passthrough_node_graph() -> None:
assert graph.get_graph(xray=True).to_json() == graph.get_graph(xray=False).to_json()
@pytest.mark.parametrize("subgraph_persist", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
def test_parent_command(
request: pytest.FixtureRequest, checkpointer_name: str, subgraph_persist: bool
) -> None:
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
@@ -5527,7 +5530,7 @@ def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str)
subgraph_builder = StateGraph(MessagesState)
subgraph_builder.add_node("tool", get_user_name)
subgraph_builder.add_edge(START, "tool")
subgraph = subgraph_builder.compile()
subgraph = subgraph_builder.compile(checkpointer=subgraph_persist)
class CustomParentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
@@ -6873,7 +6876,7 @@ def test_sync_streaming_with_functional_api() -> None:
should be greater than the time delay between the two tasks.
"""
time_delay = 0.01
time_delay = 0.05
@task()
def slow() -> dict:
@@ -8769,3 +8772,76 @@ def test_get_graph_root_channel(snapshot: SnapshotAssertion) -> None:
assert json.dumps(graph.get_graph().to_json(), indent=2) == snapshot
assert graph.get_graph().draw_mermaid(with_styles=False) == snapshot
def test_imp_exception(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
@task()
def my_task(number: int):
time.sleep(0.1)
return number * 2
@task()
def task_with_exception(number: int):
time.sleep(0.1)
raise Exception("This is a test exception")
@entrypoint(checkpointer=sync_checkpointer)
def my_workflow(number: int):
my_task(number).result()
try:
task_with_exception(number).result()
except Exception as e:
print(f"Exception caught: {e}")
my_task(number).result()
return "done"
thread1 = {"configurable": {"thread_id": "1"}}
assert my_workflow.invoke(1, thread1) == "done"
assert [c for c in my_workflow.stream(1, thread1)] == [
{"my_task": 2},
{"my_task": 2},
{"my_workflow": "done"},
]
@pytest.mark.parametrize("subgraph_persist", [True, False])
def test_parent_command_goto(
sync_checkpointer: BaseCheckpointSaver, subgraph_persist: bool
) -> None:
class State(TypedDict):
dialog_state: Annotated[list[str], operator.add]
def node_a_child(state):
return {"dialog_state": ["a_child_state"]}
def node_b_child(state):
return Command(
graph=Command.PARENT,
goto="node_b_parent",
update={"dialog_state": ["b_child_state"]},
)
sub_builder = StateGraph(State)
sub_builder.add_node(node_a_child)
sub_builder.add_node(node_b_child)
sub_builder.add_edge(START, "node_a_child")
sub_builder.add_edge("node_a_child", "node_b_child")
sub_graph = sub_builder.compile(checkpointer=subgraph_persist)
def node_b_parent(state):
return {"dialog_state": ["node_b_parent"]}
main_builder = StateGraph(State)
main_builder.add_node(node_b_parent)
main_builder.add_edge(START, "subgraph_node")
main_builder.add_node("subgraph_node", sub_graph, destinations=("node_b_parent",))
main_graph = main_builder.compile(sync_checkpointer, name="parent")
config = {"configurable": {"thread_id": 1}}
assert main_graph.invoke(input={"dialog_state": ["init_state"]}, config=config) == {
"dialog_state": ["init_state", "b_child_state", "node_b_parent"]
}
+341 -2
View File
@@ -6772,8 +6772,9 @@ async def test_debug_nested_subgraphs(async_checkpointer: BaseCheckpointSaver):
assert stream_task.get("state") == history_task.state
@pytest.mark.parametrize("subgraph_persist", [True, False])
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_parent_command(checkpointer_name: str) -> None:
async def test_parent_command(checkpointer_name: str, subgraph_persist: bool) -> None:
from langchain_core.messages import BaseMessage
from langchain_core.tools import tool
@@ -6785,7 +6786,7 @@ async def test_parent_command(checkpointer_name: str) -> None:
subgraph_builder = StateGraph(MessagesState)
subgraph_builder.add_node("tool", get_user_name)
subgraph_builder.add_edge(START, "tool")
subgraph = subgraph_builder.compile()
subgraph = subgraph_builder.compile(checkpointer=subgraph_persist)
class CustomParentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
@@ -9148,3 +9149,341 @@ async def test_draw_invalid():
{"source": "nothing", "target": "__end__"},
],
}
@NEEDS_CONTEXTVARS
async def test_imp_exception(
async_checkpointer: BaseCheckpointSaver,
) -> None:
@task()
async def my_task(number: int):
await asyncio.sleep(0.1)
return number * 2
@task()
async def task_with_exception(number: int):
await asyncio.sleep(0.1)
raise Exception("This is a test exception")
@entrypoint(checkpointer=async_checkpointer)
async def my_workflow(number: int):
await my_task(number)
try:
await task_with_exception(number)
except Exception as e:
print(f"Exception caught: {e}")
await my_task(number)
return "done"
thread1 = {"configurable": {"thread_id": "1"}}
assert await my_workflow.ainvoke(1, thread1) == "done"
assert [c async for c in my_workflow.astream(1, thread1)] == [
{"my_task": 2},
{"my_task": 2},
{"my_workflow": "done"},
]
assert [c async for c in my_workflow.astream_events(1, thread1)] == [
{
"event": "on_chain_start",
"data": {"input": 1},
"name": "LangGraph",
"tags": [],
"run_id": AnyStr(),
"metadata": {"thread_id": "1"},
"parent_ids": [],
},
{
"event": "on_chain_start",
"data": {"input": 1},
"name": "my_workflow",
"tags": ["graph:step:4"],
"run_id": AnyStr(),
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_workflow",
"langgraph_triggers": ("__start__",),
"langgraph_path": ("__pregel_pull", "my_workflow"),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [AnyStr()],
},
{
"event": "on_chain_start",
"data": {"input": {"number": 1}},
"name": "my_task",
"tags": ["seq:step:1"],
"run_id": AnyStr(),
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_stream",
"run_id": AnyStr(),
"name": "my_task",
"tags": ["seq:step:1"],
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"data": {"chunk": 2},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_end",
"data": {"output": 2, "input": {"number": 1}},
"run_id": AnyStr(),
"name": "my_task",
"tags": ["seq:step:1"],
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_stream",
"run_id": AnyStr(),
"name": "LangGraph",
"tags": [],
"metadata": {"thread_id": "1"},
"data": {"chunk": {"my_task": 2}},
"parent_ids": [],
},
{
"event": "on_chain_start",
"data": {"input": {"number": 1}},
"name": "task_with_exception",
"tags": ["seq:step:1"],
"run_id": AnyStr(),
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_start",
"data": {"input": {"number": 1}},
"name": "my_task",
"tags": ["seq:step:1"],
"run_id": AnyStr(),
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_stream",
"run_id": AnyStr(),
"name": "my_task",
"tags": ["seq:step:1"],
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"data": {"chunk": 2},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_end",
"data": {"output": 2, "input": {"number": 1}},
"run_id": AnyStr(),
"name": "my_task",
"tags": ["seq:step:1"],
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_task",
"langgraph_triggers": ("__pregel_push",),
"langgraph_path": (
"__pregel_push",
("__pregel_pull", "my_workflow"),
2,
True,
),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [
AnyStr(),
AnyStr(),
],
},
{
"event": "on_chain_stream",
"run_id": AnyStr(),
"name": "my_workflow",
"tags": ["graph:step:4"],
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_workflow",
"langgraph_triggers": ("__start__",),
"langgraph_path": ("__pregel_pull", "my_workflow"),
"langgraph_checkpoint_ns": AnyStr(),
},
"data": {"chunk": "done"},
"parent_ids": [AnyStr()],
},
{
"event": "on_chain_stream",
"run_id": AnyStr(),
"name": "LangGraph",
"tags": [],
"metadata": {"thread_id": "1"},
"data": {"chunk": {"my_task": 2}},
"parent_ids": [],
},
{
"event": "on_chain_end",
"data": {"output": "done", "input": 1},
"run_id": AnyStr(),
"name": "my_workflow",
"tags": ["graph:step:4"],
"metadata": {
"thread_id": "1",
"langgraph_step": 4,
"langgraph_node": "my_workflow",
"langgraph_triggers": ("__start__",),
"langgraph_path": ("__pregel_pull", "my_workflow"),
"langgraph_checkpoint_ns": AnyStr(),
},
"parent_ids": [AnyStr()],
},
{
"event": "on_chain_stream",
"run_id": AnyStr(),
"name": "LangGraph",
"tags": [],
"metadata": {"thread_id": "1"},
"data": {"chunk": {"my_workflow": "done"}},
"parent_ids": [],
},
{
"event": "on_chain_end",
"data": {"output": "done"},
"run_id": AnyStr(),
"name": "LangGraph",
"tags": [],
"metadata": {"thread_id": "1"},
"parent_ids": [],
},
]
@pytest.mark.parametrize("subgraph_persist", [True, False])
async def test_parent_command_goto(
async_checkpointer: BaseCheckpointSaver, subgraph_persist: bool
) -> None:
class State(TypedDict):
dialog_state: Annotated[list[str], operator.add]
async def node_a_child(state):
return {"dialog_state": ["a_child_state"]}
async def node_b_child(state):
return Command(
graph=Command.PARENT,
goto="node_b_parent",
update={"dialog_state": ["b_child_state"]},
)
sub_builder = StateGraph(State)
sub_builder.add_node(node_a_child)
sub_builder.add_node(node_b_child)
sub_builder.add_edge(START, "node_a_child")
sub_builder.add_edge("node_a_child", "node_b_child")
sub_graph = sub_builder.compile(checkpointer=subgraph_persist)
async def node_b_parent(state):
return {"dialog_state": ["node_b_parent"]}
main_builder = StateGraph(State)
main_builder.add_node(node_b_parent)
main_builder.add_edge(START, "subgraph_node")
main_builder.add_node("subgraph_node", sub_graph, destinations=("node_b_parent",))
main_graph = main_builder.compile(async_checkpointer, name="parent")
config = {"configurable": {"thread_id": 1}}
assert await main_graph.ainvoke(
input={"dialog_state": ["init_state"]}, config=config
) == {"dialog_state": ["init_state", "b_child_state", "node_b_parent"]}
+2 -2
View File
@@ -1197,7 +1197,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "0.4.5"
version = "0.4.8"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1416,7 +1416,7 @@ inmem = [
[[package]]
name = "langgraph-prebuilt"
version = "0.1.8"
version = "0.2.2"
source = { editable = "../prebuilt" }
dependencies = [
{ name = "langchain-core" },
@@ -240,7 +240,7 @@ def _validate_chat_history(
def create_react_agent(
model: Union[str, LanguageModelLike],
tools: Union[Sequence[Union[BaseTool, Callable]], ToolNode],
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
*,
prompt: Optional[Prompt] = None,
response_format: Optional[
@@ -420,12 +420,13 @@ def create_react_agent(
else AgentState
)
llm_builtin_tools: list[dict] = []
if isinstance(tools, ToolNode):
tool_classes = list(tools.tools_by_name.values())
tool_node = tools
else:
tool_node = ToolNode(tools)
# get the tool functions wrapped in a tool class from the ToolNode
llm_builtin_tools = [t for t in tools if isinstance(t, dict)]
tool_node = ToolNode([t for t in tools if not isinstance(t, dict)])
tool_classes = list(tool_node.tools_by_name.values())
if isinstance(model, str):
@@ -442,8 +443,12 @@ def create_react_agent(
tool_calling_enabled = len(tool_classes) > 0
if _should_bind_tools(model, tool_classes) and tool_calling_enabled:
model = cast(BaseChatModel, model).bind_tools(tool_classes)
if (
_should_bind_tools(model, tool_classes)
and len(tool_classes) > 0
or (len(llm_builtin_tools) > 0)
):
model = cast(BaseChatModel, model).bind_tools(tool_classes + llm_builtin_tools) # type: ignore[operator]
model_runnable = _get_prompt_runnable(prompt) | model
@@ -722,6 +727,10 @@ def create_react_agent(
]
if pending_tool_calls:
pending_tool_calls = [
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
for call in pending_tool_calls
]
return [Send("tools", [tool_call]) for tool_call in pending_tool_calls]
elif isinstance(messages[-1], ToolMessage):
return entrypoint
+1 -162
View File
@@ -1,12 +1,7 @@
from copy import deepcopy
from typing import Any, Literal, Optional, Union, cast
from typing import Literal, Optional, Union
from langchain_core.messages import ToolCall, ToolMessage
from typing_extensions import TypedDict
from langgraph.types import Command, interrupt
from langgraph.utils.runnable import RunnableCallable
class HumanInterruptConfig(TypedDict):
"""Configuration that defines what actions are allowed for a human interrupt.
@@ -93,159 +88,3 @@ class HumanResponse(TypedDict):
type: Literal["accept", "ignore", "response", "edit"]
args: Union[None, str, ActionRequest]
class InterruptToolNode(RunnableCallable):
"""Prebuilt post model hook node used to enable common patterns for tool interrupts.
For any tools with specified policies, an interrupt will be raised when the LLM returns
a tool call for said tool. The interrupt policy will be used to determine what sort of resume logic is allowed.
Any of the following resume patterns are supported:
* accept: the tool call is executed as planned
* edit: the args for the tool call are edited and then the tool call is executed
* response: text response/feedback is fed back into the LLM
* ignore: the current tool call is ignored / skipped
Args:
**interrupt_policy: a mapping of tool names to [`HumanInterruptConfig`][prebuilt.interrupt.HumanInterruptConfig] dictionaries
specifying which interrupt patterns to enable for said tool.
Example:
```python
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.prebuilt.interrupt import HumanInterruptConfig, InterruptToolNode
from langgraph.types import Command
def book_hotel(hotel_name: str) -> str:
'''Book a room at the provided hotel.'''
# Some hotel API calls, a sensitive / expensive operation
return f"Booked a hotel at {hotel_name}."
agent = create_react_agent(
"openai:gpt-4.1",
tools=[book_hotel],
prompt="You are a hotel booking assistant.",
post_model_hook=InterruptToolNode(
book_hotel=HumanInterruptConfig(
allow_accept=True,
allow_edit=True,
allow_ignore=True,
allow_respond=True,
)
),
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": 1}}
response = agent.invoke(
{"messages": [{"role": "user", "content": "please book a hotel at the hilton inn in boston."}]},
config=config,
)
response = agent.invoke(Command(resume={"type": "accept"}), config=config)
```
"""
def __init__(self, **interrupt_policy: HumanInterruptConfig):
super().__init__(self._func, self._afunc)
self.interrupt_policy = interrupt_policy
def _interrupt(
self,
tool_call: ToolCall,
interrupt_config: HumanInterruptConfig,
) -> Union[ToolCall, ToolMessage]:
"""Interrupt before a tool call and ask for human input."""
call_id = tool_call["id"]
tool_name = tool_call["name"]
request = HumanInterrupt(
action_request=ActionRequest(
action=tool_name,
args=tool_call["args"],
),
config=interrupt_config,
description=f"Please review tool call for `{tool_name}` before execution.",
)
response = interrupt([request])
# resume provided by agent inbox as a list
response = response[0] if isinstance(response, list) else response
try:
response_type = response.get("type")
except AttributeError:
raise TypeError(
f"Unexpected resume value: {response}."
f"Expected a dict with `'type'` key."
)
if response_type == "accept" and interrupt_config["allow_accept"]:
return tool_call
elif response_type == "edit" and interrupt_config["allow_edit"]:
return ToolCall(
args=cast(ActionRequest, response)["args"]["args"],
name=tool_name,
id=call_id,
type="tool_call",
)
elif response_type == "response" and interrupt_config["allow_respond"]:
return ToolMessage(
content=cast(str, response["args"]),
name=tool_name,
tool_call_id=call_id,
status="error",
)
elif response_type == "ignore" and interrupt_config["allow_ignore"]:
return ToolMessage(
content=f"User ignored the tool call for `{tool_name}` with id {call_id}",
name=tool_name,
tool_call_id=call_id,
status="success",
)
allowed_types = [
type_name
for type_name, is_allowed in {
"accept": interrupt_config["allow_accept"],
"edit": interrupt_config["allow_edit"],
"response": interrupt_config["allow_respond"],
"ignore": interrupt_config["allow_ignore"],
}.items()
if is_allowed
]
raise ValueError(
f"Unexpected human response: {response}. "
f"Expected one with `'type'` in {allowed_types} based on {tool_name}'s interrupt configuration."
)
def _func(self, input: dict[str, Any]) -> Command:
ai_msg = input["messages"][-1]
tool_calls: list[ToolCall] = deepcopy(ai_msg.tool_calls) or []
tool_messages: list[ToolMessage] = []
for idx, tool_call in enumerate(tool_calls):
if interrupt_config := self.interrupt_policy.get(tool_call["name"]):
interrupt_result = self._interrupt(
tool_call=tool_call, interrupt_config=interrupt_config
)
if isinstance(interrupt_result, ToolMessage):
tool_messages.append(interrupt_result)
else:
tool_calls[idx] = interrupt_result
updated_ai_msg = ai_msg.copy(update={"tool_calls": tool_calls})
# conditional routing logic for post_model_hook will direct to the tools node
# or agent node depending on if there are pending tool calls
return {"messages": [updated_ai_msg, *tool_messages]}
async def _afunc(self, input: dict[str, Any]) -> Command:
return self._func(input)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-prebuilt"
version = "0.1.8"
version = "0.2.2"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
requires-python = ">=3.9"
@@ -1,191 +0,0 @@
import pytest
from langchain_core.messages import ToolMessage
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.prebuilt import create_react_agent
from langgraph.prebuilt.interrupt import HumanInterruptConfig, InterruptToolNode
from langgraph.types import Command
from tests.model import FakeToolCallingModel
def hello_tool(name: str) -> str:
"""Return a greeting for the provided person."""
return f"Hello, {name}!"
post_model_hook = InterruptToolNode(
hello_tool=HumanInterruptConfig(
allow_accept=True,
allow_edit=True,
allow_ignore=True,
allow_respond=True,
)
)
default_model = FakeToolCallingModel(
tool_calls=[
[
{
"name": "hello_tool",
"args": {"name": "lady gaga"},
"id": "some-random-id",
}
]
]
)
def test_interrupt_surfaced(
request: pytest.FixtureRequest,
sync_checkpointer: BaseCheckpointSaver,
) -> None:
agent = create_react_agent(
default_model,
[hello_tool],
checkpointer=sync_checkpointer,
post_model_hook=post_model_hook,
)
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
result = agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
interrupt_data = result["__interrupt__"]
assert interrupt_data[0].value == [
{
"action_request": {"action": "hello_tool", "args": {"name": "lady gaga"}},
"config": {
"allow_accept": True,
"allow_edit": True,
"allow_ignore": True,
"allow_respond": True,
},
"description": "Please review tool call for `hello_tool` before execution.",
}
]
response = agent.invoke(Command(resume={"type": "accept"}), config=config)
tool_message: ToolMessage = response["messages"][-2]
assert tool_message.content == "Hello, lady gaga!"
assert tool_message.name == "hello_tool"
@pytest.mark.parametrize(
"resume, expected_content",
[
({"type": "accept"}, "Hello, lady gaga!"),
(
{"type": "ignore"},
"User ignored the tool call for `hello_tool` with id some-random-id",
),
(
{
"type": "edit",
"args": {"action": "hello_tool", "args": {"name": "bruno mars"}},
},
"Hello, bruno mars!",
),
],
)
def test_interrupt_resume_variants(
request: pytest.FixtureRequest,
sync_checkpointer: BaseCheckpointSaver,
resume: dict,
expected_content: str,
) -> None:
agent = create_react_agent(
default_model,
[hello_tool],
checkpointer=sync_checkpointer,
post_model_hook=post_model_hook,
)
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
response = agent.invoke(Command(resume=resume), config=config)
tool_message: ToolMessage = response["messages"][-2]
assert tool_message.name == "hello_tool"
assert tool_message.content == expected_content
if resume["type"] == "edit":
ai_msg = response["messages"][-1]
assert ai_msg.tool_calls == [
{
"name": "hello_tool",
"args": {"name": "lady gaga"},
"id": "some-random-id",
"type": "tool_call",
}
]
def test_resume_with_response(
request: pytest.FixtureRequest,
sync_checkpointer: BaseCheckpointSaver,
) -> None:
model = FakeToolCallingModel(
tool_calls=[
[
{
"name": "hello_tool",
"args": {"name": "lady gaga"},
"id": "some-random-id",
}
],
[
{
"name": "hello_tool",
"args": {"name": "bruno mars"},
"id": "some-random-id-2",
}
],
]
)
agent = create_react_agent(
model,
[hello_tool],
checkpointer=sync_checkpointer,
post_model_hook=post_model_hook,
)
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
# Provide user response
agent.invoke(
Command(
resume={
"type": "response",
"args": "actually, please say hello to bruno mars",
}
),
config=config,
)
# Accept the updated call
response = agent.invoke(Command(resume={"type": "accept"}), config=config)
assert len(response["messages"]) == 6
tool_message: ToolMessage = response["messages"][-2]
assert tool_message.name == "hello_tool"
assert tool_message.content == "Hello, bruno mars!"
def test_resume_with_type_not_allowed(sync_checkpointer: BaseCheckpointSaver) -> None:
agent = create_react_agent(
default_model,
[hello_tool],
checkpointer=sync_checkpointer,
post_model_hook=post_model_hook,
)
config: RunnableConfig = {"configurable": {"thread_id": "1"}}
agent.invoke({"messages": [("user", "Say hi to lady gaga!")]}, config)
with pytest.raises(ValueError) as exc_info:
agent.invoke(Command(resume={"type": "not-allowed"}), config=config)
assert (
str(exc_info.value)
== "Unexpected human response: {'type': 'not-allowed'}. Expected one with `'type'` in ['accept', 'edit', 'response', 'ignore'] based on hello_tool's interrupt configuration."
)
+60
View File
@@ -1540,3 +1540,63 @@ def test_post_model_hook_with_structured_output() -> None:
}
},
]
@pytest.mark.parametrize(
"state_schema", [AgentStateExtraKey, AgentStateExtraKeyPydantic]
)
def test_create_react_agent_inject_vars_with_post_model_hook(
state_schema: StateSchemaType,
) -> None:
store = InMemoryStore()
namespace = ("test",)
store.put(namespace, "test_key", {"bar": 3})
if issubclass(state_schema, AgentStatePydantic):
def tool1(
some_val: int,
state: Annotated[AgentStateExtraKeyPydantic, InjectedState],
store: Annotated[BaseStore, InjectedStore()],
) -> str:
"""Tool 1 docstring."""
store_val = store.get(namespace, "test_key").value["bar"]
return some_val + state.foo + store_val
else:
def tool1(
some_val: int,
state: Annotated[dict, InjectedState],
store: Annotated[BaseStore, InjectedStore()],
) -> str:
"""Tool 1 docstring."""
store_val = store.get(namespace, "test_key").value["bar"]
return some_val + state["foo"] + store_val
tool_call = {
"name": "tool1",
"args": {"some_val": 1},
"id": "some 0",
"type": "tool_call",
}
def post_model_hook(state: dict) -> None:
return
model = FakeToolCallingModel(tool_calls=[[tool_call], []])
agent = create_react_agent(
model,
[tool1],
state_schema=state_schema,
store=store,
post_model_hook=post_model_hook,
)
input_message = HumanMessage("hi")
result = agent.invoke({"messages": [input_message], "foo": 2})
assert result["messages"] == [
input_message,
AIMessage(content="hi", tool_calls=[tool_call], id="0"),
_AnyIdToolMessage(content="6", name="tool1", tool_call_id="some 0"),
AIMessage("hi-hi-6", id="1"),
]
assert result["foo"] == 2
+743 -743
View File
File diff suppressed because it is too large Load Diff
+838 -838
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.77",
"version": "0.0.78",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
+190 -70
View File
@@ -457,6 +457,11 @@ export interface UseStreamOptions<
*/
onFinish?: (state: ThreadState<StateType>) => void;
/**
* Callback that is called when a new stream is created.
*/
onCreated?: (run: { run_id: string; thread_id: string }) => void;
/**
* Callback that is called when an update event is received.
*/
@@ -502,6 +507,15 @@ export interface UseStreamOptions<
* Callback that is called when the thread ID is updated (ie when a new thread is created).
*/
onThreadId?: (threadId: string) => void;
/** Will reconnect the stream on mount */
reconnectOnMount?: boolean | (() => RunMetadataStorage);
}
interface RunMetadataStorage {
getItem(key: `lg:stream:${string}`): string | null;
setItem(key: `lg:stream:${string}`, value: string): void;
removeItem(key: `lg:stream:${string}`): void;
}
export interface UseStream<
@@ -590,6 +604,11 @@ export interface UseStream<
* The ID of the assistant to use.
*/
assistantId: string;
/**
* Join an active stream.
*/
joinStream: (runId: string) => Promise<void>;
}
type ConfigWithConfigurable<ConfigurableType extends Record<string, unknown>> =
@@ -619,6 +638,7 @@ interface SubmitOptions<
* @default false
*/
streamSubgraphs?: boolean;
streamResumable?: boolean;
}
export function useStream<
@@ -647,7 +667,17 @@ export function useStream<
| ErrorStreamEvent
| FeedbackStreamEvent;
let { assistantId, messagesKey, onError, onFinish } = options;
let { assistantId, messagesKey, onCreated, onError, onFinish } = options;
const reconnectOnMountRef = useRef(options.reconnectOnMount);
const runMetadataStorage = useMemo(() => {
if (typeof window === "undefined") return null;
const storage = reconnectOnMountRef.current;
if (storage === true) return window.sessionStorage;
if (typeof storage === "function") return storage();
return null;
}, []);
messagesKey ??= "messages";
const client = useMemo(
@@ -722,6 +752,7 @@ export function useStream<
// TODO: this should be done on the server to avoid pagination
// TODO: should we permit adapter? SWR / React Query?
// TODO: make this only when branching is expected
const history = useThreadHistory<StateType>(
threadId,
client,
@@ -800,15 +831,23 @@ export function useStream<
);
})();
const stop = useCallback(() => {
const stop = () => {
if (abortRef.current != null) abortRef.current.abort();
abortRef.current = null;
}, []);
const submit = async (
values: UpdateType | null | undefined,
submitOptions?: SubmitOptions<StateType, ConfigurableType>,
) => {
if (runMetadataStorage && threadId) {
const runId = runMetadataStorage.getItem(`lg:stream:${threadId}`);
if (runId) client.runs.cancel(threadId, runId);
runMetadataStorage.removeItem(`lg:stream:${threadId}`);
}
};
async function consumeStream(
action: (signal: AbortSignal) => Promise<{
onSuccess: () => Promise<ThreadState<StateType>[]>;
stream: AsyncGenerator<EventStreamEvent>;
}>,
) {
try {
setIsLoading(true);
setStreamError(undefined);
@@ -816,69 +855,10 @@ export function useStream<
submittingRef.current = true;
abortRef.current = new AbortController();
// Unbranch things
const newPath = submitOptions?.checkpoint?.checkpoint_id
? branchByCheckpoint[submitOptions?.checkpoint?.checkpoint_id]?.branch
: undefined;
if (newPath != null) setBranch(newPath ?? "");
// Assumption: we're setting the initial value
// Used for instant feedback
setStreamValues(() => {
const values = { ...historyValues };
if (submitOptions?.optimisticValues != null) {
return {
...values,
...(typeof submitOptions.optimisticValues === "function"
? submitOptions.optimisticValues(values)
: submitOptions.optimisticValues),
};
}
return values;
});
let usableThreadId = threadId;
if (!usableThreadId) {
const thread = await client.threads.create();
onThreadId(thread.thread_id);
usableThreadId = thread.thread_id;
}
const streamMode = unique([
...(submitOptions?.streamMode ?? []),
...trackStreamModeRef.current,
...callbackStreamMode,
]);
const checkpoint =
submitOptions?.checkpoint ?? threadHead?.checkpoint ?? undefined;
// @ts-expect-error
if (checkpoint != null) delete checkpoint.thread_id;
const run = client.runs.stream(usableThreadId, assistantId, {
input: values as Record<string, unknown>,
config: submitOptions?.config,
command: submitOptions?.command,
interruptBefore: submitOptions?.interruptBefore,
interruptAfter: submitOptions?.interruptAfter,
metadata: submitOptions?.metadata,
multitaskStrategy: submitOptions?.multitaskStrategy,
onCompletion: submitOptions?.onCompletion,
onDisconnect: submitOptions?.onDisconnect ?? "cancel",
signal: abortRef.current.signal,
checkpoint,
streamMode,
streamSubgraphs: submitOptions?.streamSubgraphs,
}) as AsyncGenerator<EventStreamEvent>;
const run = await action(abortRef.current.signal);
let streamError: StreamError | undefined;
for await (const { event, data } of run) {
for await (const { event, data } of run.stream) {
if (event === "error") {
streamError = new StreamError(data);
break;
@@ -930,9 +910,9 @@ export function useStream<
}
// TODO: stream created checkpoints to avoid an unnecessary network request
const result = await history.mutate(usableThreadId);
setStreamValues(null);
const result = await run.onSuccess();
setStreamValues(null);
if (streamError != null) throw streamError;
const lastHead = result.at(0);
@@ -956,8 +936,146 @@ export function useStream<
submittingRef.current = false;
abortRef.current = null;
}
}
const joinStream = async (runId: string, lastEventId?: string) => {
lastEventId ??= "-1";
if (!threadId) return;
await consumeStream(async (signal: AbortSignal) => {
const stream = client.runs.joinStream(threadId, runId, {
signal,
lastEventId,
}) as AsyncGenerator<EventStreamEvent>;
return {
onSuccess: () => {
runMetadataStorage?.removeItem(`lg:stream:${threadId}`);
return history.mutate(threadId);
},
stream,
};
});
};
const submit = async (
values: UpdateType | null | undefined,
submitOptions?: SubmitOptions<StateType, ConfigurableType>,
) => {
await consumeStream(async (signal: AbortSignal) => {
// Unbranch things
const newPath = submitOptions?.checkpoint?.checkpoint_id
? branchByCheckpoint[submitOptions?.checkpoint?.checkpoint_id]?.branch
: undefined;
if (newPath != null) setBranch(newPath ?? "");
// Assumption: we're setting the initial value
// Used for instant feedback
setStreamValues(() => {
const values = { ...historyValues };
if (submitOptions?.optimisticValues != null) {
return {
...values,
...(typeof submitOptions.optimisticValues === "function"
? submitOptions.optimisticValues(values)
: submitOptions.optimisticValues),
};
}
return values;
});
let usableThreadId = threadId;
if (!usableThreadId) {
const thread = await client.threads.create();
onThreadId(thread.thread_id);
usableThreadId = thread.thread_id;
}
const streamMode = unique([
...(submitOptions?.streamMode ?? []),
...trackStreamModeRef.current,
...callbackStreamMode,
]);
const checkpoint =
submitOptions?.checkpoint ?? threadHead?.checkpoint ?? undefined;
// @ts-expect-error
if (checkpoint != null) delete checkpoint.thread_id;
let rejoinKey: `lg:stream:${string}` | undefined;
const stream = client.runs.stream(usableThreadId, assistantId, {
input: values as Record<string, unknown>,
config: submitOptions?.config,
command: submitOptions?.command,
interruptBefore: submitOptions?.interruptBefore,
interruptAfter: submitOptions?.interruptAfter,
metadata: submitOptions?.metadata,
multitaskStrategy: submitOptions?.multitaskStrategy,
onCompletion: submitOptions?.onCompletion,
onDisconnect:
submitOptions?.onDisconnect ??
(runMetadataStorage ? "continue" : "cancel"),
signal,
checkpoint,
streamMode,
streamSubgraphs: submitOptions?.streamSubgraphs,
streamResumable: submitOptions?.streamResumable ?? !!runMetadataStorage,
onRunCreated(params) {
const runParams = {
run_id: params.run_id,
thread_id: params.thread_id ?? usableThreadId,
};
if (runMetadataStorage) {
rejoinKey = `lg:stream:${runParams.thread_id}`;
runMetadataStorage.setItem(rejoinKey, runParams.run_id);
}
onCreated?.(runParams);
},
}) as AsyncGenerator<EventStreamEvent>;
return {
stream,
onSuccess: () => {
if (rejoinKey) runMetadataStorage?.removeItem(rejoinKey);
return history.mutate(usableThreadId);
},
};
});
};
const reconnectKey = useMemo(() => {
if (!runMetadataStorage || isLoading) return undefined;
if (typeof window === "undefined") return undefined;
const runId = runMetadataStorage?.getItem(`lg:stream:${threadId}`);
if (!runId) return undefined;
return { runId, threadId };
}, [runMetadataStorage, isLoading, threadId]);
const shouldReconnect = !!runMetadataStorage;
const reconnectRef = useRef({ threadId, shouldReconnect });
const joinStreamRef = useRef<typeof joinStream>(joinStream);
joinStreamRef.current = joinStream;
useEffect(() => {
// reset shouldReconnect when switching threads
if (reconnectRef.current.threadId !== threadId) {
reconnectRef.current = { threadId, shouldReconnect };
}
}, [threadId, shouldReconnect]);
useEffect(() => {
if (reconnectKey && reconnectRef.current.shouldReconnect) {
reconnectRef.current.shouldReconnect = false;
joinStreamRef.current?.(reconnectKey.runId);
}
}, [reconnectKey]);
const error = streamError ?? historyError;
const values = streamValues ?? historyValues;
@@ -976,6 +1094,8 @@ export function useStream<
stop,
submit,
joinStream,
branch,
setBranch,