Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
746142fb07 | ||
|
|
b9c9c32c31 | ||
|
|
c89fe4c45d | ||
|
|
b0e28851a6 | ||
|
|
48fb91deda | ||
|
|
efb282a197 | ||
|
|
89ce6ea2a8 | ||
|
|
1e87312d1f | ||
|
|
bcc6485f6c | ||
|
|
ce5f248e3b | ||
|
|
5602c29668 | ||
|
|
19ca6b416b | ||
|
|
2553ae0b87 | ||
|
|
3e4b69af3f | ||
|
|
0c6367c186 | ||
|
|
99860b5713 | ||
|
|
a69860baa6 | ||
|
|
6fc21046cd | ||
|
|
754420e9a2 | ||
|
|
15126ad827 | ||
|
|
6633173918 | ||
|
|
0139e11ae5 | ||
|
|
bfbe55ab64 | ||
|
|
36478eb745 | ||
|
|
8fb91569b9 | ||
|
|
9bf6728354 | ||
|
|
126a8f5bc6 | ||
|
|
9170f636d0 | ||
|
|
8207d3fefb | ||
|
|
ade3f372a5 | ||
|
|
3a55d1137b | ||
|
|
bece43dc67 | ||
|
|
913b8d5e95 | ||
|
|
29f6ea7f61 | ||
|
|
3c0d9346c2 | ||
|
|
0ebb78d9b2 | ||
|
|
70153ceba2 | ||
|
|
a9c87ed8b6 | ||
|
|
837fe59e24 | ||
|
|
596a26461c | ||
|
|
aff3be03dc | ||
|
|
1000b81eca | ||
|
|
10c6ed7320 | ||
|
|
28e0e7f3ae | ||
|
|
87fa661ac0 | ||
|
|
3189b8fcda | ||
|
|
74b18acfc2 | ||
|
|
1ff5e2966b | ||
|
|
1ba2b3fba9 | ||
|
|
102b1f63f3 | ||
|
|
74dbccd408 | ||
|
|
f5a2410ddc | ||
|
|
4cade428d8 | ||
|
|
5823a659fc | ||
|
|
f2c32727e8 | ||
|
|
9d71d941fd | ||
|
|
0db618ae75 | ||
|
|
49746ab3a3 | ||
|
|
5c7ef9a4fc | ||
|
|
62e688bb37 | ||
|
|
42eff39cd0 | ||
|
|
39172f2ff9 | ||
|
|
0202c83e73 | ||
|
|
ab924c72fa | ||
|
|
1833889316 | ||
|
|
7a6bdb3441 | ||
|
|
d2875cc576 | ||
|
|
364fdf5dfe | ||
|
|
fe5d303ccd | ||
|
|
c9fa11ae7d | ||
|
|
988805d60b |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, {}):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||