mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-19 06:05:44 +02:00
Compare commits
217
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f649abd0a | ||
|
|
1febec7c0d | ||
|
|
a4eb4c6942 | ||
|
|
8e1cd0e225 | ||
|
|
98935e1ffd | ||
|
|
328ef609af | ||
|
|
486d5412af | ||
|
|
abc0c8c223 | ||
|
|
5bbb9dae57 | ||
|
|
fed60e713c | ||
|
|
4f4e7a6981 | ||
|
|
3351d4f6c5 | ||
|
|
b4900341e4 | ||
|
|
65f515e020 | ||
|
|
0d0665a6e3 | ||
|
|
93b8525dc1 | ||
|
|
aeb6f784e1 | ||
|
|
3eedeac0d4 | ||
|
|
b09e7b20b0 | ||
|
|
26ce731eab | ||
|
|
55593446f8 | ||
|
|
7082e2613e | ||
|
|
ceeb9636ee | ||
|
|
f7788abbb6 | ||
|
|
9bd430142a | ||
|
|
72dac006f4 | ||
|
|
54d848913f | ||
|
|
7021e81150 | ||
|
|
b977045679 | ||
|
|
a933776436 | ||
|
|
588373c2d5 | ||
|
|
267962bece | ||
|
|
4ae29b6e2a | ||
|
|
3c0de26914 | ||
|
|
a570662773 | ||
|
|
9766068896 | ||
|
|
7e8eef88ca | ||
|
|
e3e63c70c9 | ||
|
|
312f0982bc | ||
|
|
153245145e | ||
|
|
c95abd88a1 | ||
|
|
a2b357bed5 | ||
|
|
7090d7e9a8 | ||
|
|
b3fa43e4a6 | ||
|
|
b1779cf348 | ||
|
|
26d18d3ca5 | ||
|
|
12052d7d26 | ||
|
|
e3a30a9b69 | ||
|
|
ff1370a9a5 | ||
|
|
679a7365da | ||
|
|
b2522ffe19 | ||
|
|
4212a795a0 | ||
|
|
517d67aa32 | ||
|
|
feaf14765a | ||
|
|
cc6063c729 | ||
|
|
013397042e | ||
|
|
9a775d9c9f | ||
|
|
2c945ceb68 | ||
|
|
39eabd0fb8 | ||
|
|
e5cc2e2044 | ||
|
|
f00c0515e7 | ||
|
|
d87c0d4d53 | ||
|
|
fb40a974c8 | ||
|
|
d63bfc6879 | ||
|
|
97dd30711a | ||
|
|
016a9c1936 | ||
|
|
a2d6837fba | ||
|
|
f5bb2a3b04 | ||
|
|
f807b73092 | ||
|
|
167405daf2 | ||
|
|
c6360e5408 | ||
|
|
f0505155a2 | ||
|
|
886df0fa86 | ||
|
|
3f1792d6ba | ||
|
|
9208052a94 | ||
|
|
7866bd2718 | ||
|
|
7c11325e23 | ||
|
|
d99dc7d81b | ||
|
|
973ad76a58 | ||
|
|
36e49eb190 | ||
|
|
66b9a7dee7 | ||
|
|
5494855ffa | ||
|
|
38d93a324c | ||
|
|
07c65321c1 | ||
|
|
1dbdd7df2e | ||
|
|
dab29ce094 | ||
|
|
0388534b9f | ||
|
|
81077e7c3a | ||
|
|
29a0042149 | ||
|
|
e9162e2516 | ||
|
|
0f6c001c25 | ||
|
|
7f26325c87 | ||
|
|
3c4ce3f945 | ||
|
|
84ef939bf4 | ||
|
|
bdc22ea127 | ||
|
|
5abbb79e1b | ||
|
|
0a5220aa07 | ||
|
|
970e68edcc | ||
|
|
da1a80e86d | ||
|
|
c4b240e0c2 | ||
|
|
c2052d11c2 | ||
|
|
dc0281b99c | ||
|
|
3a860ad537 | ||
|
|
7051bccc30 | ||
|
|
199e41b228 | ||
|
|
229a9e19a8 | ||
|
|
3c3a1a1f35 | ||
|
|
f11127648e | ||
|
|
29f833b1a7 | ||
|
|
7a3ea42743 | ||
|
|
a94902db8a | ||
|
|
9fd152ef3a | ||
|
|
03bc9ba6e6 | ||
|
|
7fe6f88876 | ||
|
|
1d88affd29 | ||
|
|
6906e12edb | ||
|
|
7b4c29a20d | ||
|
|
16bfa80b58 | ||
|
|
cb10437c3f | ||
|
|
2bcf1c0a20 | ||
|
|
00964b18f6 | ||
|
|
0d5c6201d3 | ||
|
|
86d2847dab | ||
|
|
b3a4eaa967 | ||
|
|
87fc519ce7 | ||
|
|
ef3a1ee997 | ||
|
|
311e16dffd | ||
|
|
c83b8f6d04 | ||
|
|
62d3a85b07 | ||
|
|
810ae0ef51 | ||
|
|
2ff49d2200 | ||
|
|
d0567dc7be | ||
|
|
ea64ac5c07 | ||
|
|
090b53ccc1 | ||
|
|
0e872e7482 | ||
|
|
3ad966e057 | ||
|
|
a73f9affab | ||
|
|
89a0859928 | ||
|
|
75c502cd93 | ||
|
|
0cc45f7a35 | ||
|
|
efb1dd6a10 | ||
|
|
6edb213fe7 | ||
|
|
c0513076a2 | ||
|
|
c043f148a6 | ||
|
|
366b5e04c7 | ||
|
|
72239d2228 | ||
|
|
b7f238975a | ||
|
|
a3c45141ee | ||
|
|
f7b899a54d | ||
|
|
95477277a2 | ||
|
|
64b446f671 | ||
|
|
c2d2e44794 | ||
|
|
f60b9f3b43 | ||
|
|
e3acfa3435 | ||
|
|
0f92c89529 | ||
|
|
7cba75ec35 | ||
|
|
8408ba3a3e | ||
|
|
c8c58b30d5 | ||
|
|
b4bed3329c | ||
|
|
4eec3aa69e | ||
|
|
35e3276e34 | ||
|
|
b346f4ead8 | ||
|
|
fef748e6cc | ||
|
|
19cc95f7b6 | ||
|
|
eef65d94ac | ||
|
|
72d497e052 | ||
|
|
32f58258aa | ||
|
|
87d57b434a | ||
|
|
4bbbb7d246 | ||
|
|
c1ce3c6b5f | ||
|
|
511de6f5f6 | ||
|
|
4dc08cd724 | ||
|
|
d7b9b3b01d | ||
|
|
a82ded65c6 | ||
|
|
1ca49fa568 | ||
|
|
4eb3cd32fa | ||
|
|
80d2a315aa | ||
|
|
82c9aa8485 | ||
|
|
8ecfafefbf | ||
|
|
010564cbb3 | ||
|
|
18a3fa4a00 | ||
|
|
de8e487ff1 | ||
|
|
e5ebdff4d8 | ||
|
|
f283dac325 | ||
|
|
639501809c | ||
|
|
36e6b89081 | ||
|
|
90639e6cd7 | ||
|
|
dec0b7f439 | ||
|
|
04657408f8 | ||
|
|
971d746061 | ||
|
|
9a2ba8c8cd | ||
|
|
b50d41bbf3 | ||
|
|
b71fd5092b | ||
|
|
f9b151b67f | ||
|
|
7ba9a66301 | ||
|
|
1b85764bf6 | ||
|
|
5e4c928948 | ||
|
|
ae282e3ae1 | ||
|
|
de3b654735 | ||
|
|
d28734f287 | ||
|
|
f9409022ed | ||
|
|
8138c88b41 | ||
|
|
38332fd3c6 | ||
|
|
5606bef3dd | ||
|
|
895079bbdc | ||
|
|
6aaf80f2fd | ||
|
|
2e656d9145 | ||
|
|
1fb8e013f7 | ||
|
|
f8fe2041d9 | ||
|
|
014f8485a6 | ||
|
|
1e3953d1e0 | ||
|
|
18e71469e1 | ||
|
|
2624fc43dd | ||
|
|
0c5c2e6370 | ||
|
|
433c382280 | ||
|
|
7352ab14a2 | ||
|
|
85a76912d3 |
@@ -22,8 +22,7 @@ def test(
|
||||
# check docker available
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
# open config
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
|
||||
set("Running...")
|
||||
args = [
|
||||
|
||||
@@ -19,14 +19,19 @@ jobs:
|
||||
- "3.13"
|
||||
core-version:
|
||||
- "latest"
|
||||
ff-send-v2:
|
||||
- "false"
|
||||
include:
|
||||
- python-version: "3.11"
|
||||
core-version: ">=0.2.42,<0.3.0"
|
||||
- python-version: "3.11"
|
||||
core-version: "latest"
|
||||
ff-send-v2: "true"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/langgraph
|
||||
name: "test #${{ matrix.python-version }} (langchain-core: ${{ matrix.core-version }})"
|
||||
name: "test #${{ matrix.python-version }} (langchain-core: ${{ matrix.core-version }}, ff-send-v2: ${{ matrix.ff-send-v2 }})"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }}
|
||||
@@ -52,6 +57,8 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
env:
|
||||
LANGGRAPH_FF_SEND_V2: ${{ matrix.ff-send-v2 }}
|
||||
run: |
|
||||
make test
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ jobs:
|
||||
deploy:
|
||||
# needs: run-changed-notebooks
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MKDOCS_GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -58,8 +60,14 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install --with test
|
||||
poetry run pip install -U pytest pytest-check-links langsmith langchain GitPython
|
||||
poetry install --with test --no-root
|
||||
poetry run pip install -U \
|
||||
pytest \
|
||||
pytest-check-links \
|
||||
langsmith \
|
||||
langchain \
|
||||
GitPython \
|
||||
"git+https://${GITHUB_TOKEN}@github.com/langchain-ai/mkdocs-material-insiders.git"
|
||||
|
||||
- name: Lint Docs
|
||||
# This step lints the docs using the existing linting set up.
|
||||
@@ -80,9 +88,11 @@ jobs:
|
||||
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
|
||||
--check-links-ignore "https://x.com/.*" \
|
||||
--check-links-ignore "https://github\.com/.*" \
|
||||
--check-links-ignore "http://localhost:8123/.*" \
|
||||
--check-links-ignore "/.*\.(ipynb|html)$" \
|
||||
--check-links-ignore "https://python\.langchain\.com/.*" \
|
||||
--check-links-ignore "https://openai.com/index/memory-and-new-controls-for-chatgpt/" \
|
||||
--check-links-ignore "https://openai\.com/.*" \
|
||||
--check-links-ignore "https://pepy\.tech/.*" \
|
||||
--check-links $(find docs/site -name "index.html" | grep -v 'storm/index.html')
|
||||
|
||||
else
|
||||
@@ -95,6 +105,7 @@ jobs:
|
||||
echo "Running link check on HTML files matching changed notebook files..."
|
||||
poetry run pytest -v \
|
||||
--check-links-ignore "https://(api|web|docs)\.smith\.langchain\.com/.*" \
|
||||
--check-links-ignore "http://localhost:8123/.*" \
|
||||
--check-links-ignore "https://x.com/.*" \
|
||||
--check-links-ignore "https://github\.com/.*" \
|
||||
--check-links-ignore "/.*\.(ipynb|html)$" \
|
||||
|
||||
@@ -238,7 +238,7 @@ final_state["messages"][-1].content
|
||||
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
|
||||
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
|
||||
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
|
||||
* [Cloud (beta)](https://langchain-ai.github.io/langgraph/cloud/): With one click, deploy LangGraph applications to LangGraph Cloud.
|
||||
* [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform): LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -36,10 +36,11 @@ NOTEBOOKS_NO_EXECUTION = [
|
||||
"docs/docs/tutorials/rag/langgraph_self_rag_local.ipynb",
|
||||
# this loads a massive dataset from gcp
|
||||
"docs/docs/tutorials/usaco/usaco.ipynb",
|
||||
# TODO: figure out why autogen notebook is not runnable (they are just hanging. possible due to code execution?)
|
||||
"docs/docs/how-tos/autogen-integration.ipynb",
|
||||
# TODO: need to update these notebooks to make sure they are runnable in CI
|
||||
"docs/docs/tutorials/storm/storm.ipynb", # issues only when running with VCR
|
||||
"docs/docs/tutorials/lats/lats.ipynb", # issues only when running with VCR
|
||||
"docs/docs/tutorials/multi_agent/hierarchical_agent_teams.ipynb", # taking a very long time to run
|
||||
"docs/docs/tutorials/rag/langgraph_crag.ipynb", # flakiness from tavily
|
||||
"docs/docs/tutorials/rag/langgraph_adaptive_rag.ipynb", # Cannot create a consistent method resolution error from VCR
|
||||
"docs/docs/how-tos/map-reduce.ipynb" # flakiness from structured output, only when running with VCR
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -21,7 +21,7 @@ Install the proper packages:
|
||||
Ensure you have an API key, which you can create from the [LangSmith UI](https://smith.langchain.com) (Settings > API Keys). This is required to authenticate that you have LangGraph Cloud access. After you have saved the key to a safe place, place the following line in your `.env` file:
|
||||
|
||||
```python
|
||||
LANGCHAIN_API_KEY = *********
|
||||
LANGSMITH_API_KEY = *********
|
||||
```
|
||||
|
||||
## Start the API server
|
||||
@@ -54,7 +54,7 @@ You can either initialize by passing authentication or by setting an environment
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# only pass the url argument to get_client() if you changed the default port when calling langgraph up
|
||||
client = get_client(url=<DEPLOYMENT_URL>,api_key=<LANGCHAIN_API_KEY>)
|
||||
client = get_client(url=<DEPLOYMENT_URL>,api_key=<LANGSMITH_API_KEY>)
|
||||
# Using the graph deployed with the name "agent"
|
||||
assistant_id = "agent"
|
||||
thread = await client.threads.create()
|
||||
@@ -66,7 +66,7 @@ You can either initialize by passing authentication or by setting an environment
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
// only set the apiUrl if you changed the default port when calling langgraph up
|
||||
const client = new Client({ apiUrl: <DEPLOYMENT_URL>, apiKey: <LANGCHAIN_API_KEY> });
|
||||
const client = new Client({ apiUrl: <DEPLOYMENT_URL>, apiKey: <LANGSMITH_API_KEY> });
|
||||
// Using the graph deployed with the name "agent"
|
||||
const assistantId = "agent";
|
||||
const thread = await client.threads.create();
|
||||
@@ -78,13 +78,13 @@ You can either initialize by passing authentication or by setting an environment
|
||||
curl --request POST \
|
||||
--url <DEPLOYMENT_URL>/threads \
|
||||
--header 'Content-Type: application/json'
|
||||
--header 'x-api-key: <LANGCHAIN_API_KEY>'
|
||||
--header 'x-api-key: <LANGSMITH_API_KEY>'
|
||||
```
|
||||
|
||||
|
||||
#### Initialize with environment variables
|
||||
|
||||
If you have a `LANGCHAIN_API_KEY` set in your environment, you do not need to explicitly pass authentication to the client
|
||||
If you have a `LANGSMITH_API_KEY` set in your environment, you do not need to explicitly pass authentication to the client
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -154,7 +154,7 @@ Now we can invoke our graph to ensure it is working. Make sure to change the inp
|
||||
}
|
||||
```
|
||||
|
||||
=== "CURL"
|
||||
=== "CURL"
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
|
||||
@@ -86,19 +86,19 @@ To create an assistant using the studio do the following steps:
|
||||
|
||||
1. Click on the "Create New Assistant" button:
|
||||
|
||||

|
||||

|
||||
|
||||
2. Use the create assistant pane to enter info for the assistant you wish to create, and then click create:
|
||||
1. Use the create assistant pane to enter info for the assistant you wish to create, and then click create:
|
||||
|
||||

|
||||

|
||||
|
||||
3. See that your assistant was created and is displayed in the Studio
|
||||
1. See that your assistant was created and is displayed in the Studio
|
||||
|
||||

|
||||

|
||||
|
||||
4. Click on the edit button next to the selected assistant to manage your created assistant:
|
||||
1. Click on the edit button next to the selected assistant to manage your created assistant:
|
||||
|
||||

|
||||

|
||||
|
||||
## Create a new version for your assistant
|
||||
|
||||
@@ -131,15 +131,15 @@ Let's now say we wanted to add a system prompt to our assistant. We can do this
|
||||
|
||||
1. First, click on the edit button next to the `openai_assistant`. Then, add a system prompt and click "Save New Version":
|
||||
|
||||

|
||||

|
||||
|
||||
2. Then you can see it is selected in the assistant dropdown:
|
||||
1. Then you can see it is selected in the assistant dropdown:
|
||||
|
||||

|
||||

|
||||
|
||||
3. And you can see all the version history in the edit pane for the assistant:
|
||||
1. And you can see all the version history in the edit pane for the assistant:
|
||||
|
||||

|
||||

|
||||
|
||||
## Point your assistant to a different version
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ Now we can start our two runs and join the second on euntil it has completed:
|
||||
assistant_id,
|
||||
input={"messages": [{"role": "user", "content": "what's the weather in sf?"}]},
|
||||
)
|
||||
# sleep a bit to get partial outputs from the first run
|
||||
await asyncio.sleep(2)
|
||||
run = await client.runs.create(
|
||||
thread["thread_id"],
|
||||
@@ -114,6 +115,7 @@ Now we can start our two runs and join the second on euntil it has completed:
|
||||
assistantId,
|
||||
{ input: { messages: [{ role: "human", content: "what's the weather in sf?" }] } }
|
||||
);
|
||||
// sleep a bit to get partial outputs from the first run
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
let run = await client.runs.create(
|
||||
|
||||
@@ -95,7 +95,6 @@ Now let's run a thread with the multitask parameter set to "rollback":
|
||||
assistant_id,
|
||||
input={"messages": [{"role": "user", "content": "what's the weather in sf?"}]},
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
run = await client.runs.create(
|
||||
thread["thread_id"],
|
||||
assistant_id,
|
||||
@@ -115,7 +114,6 @@ Now let's run a thread with the multitask parameter set to "rollback":
|
||||
assistantId,
|
||||
{ input: { messages: [{ role: "human", content: "what's the weather in sf?" }] } }
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
let run = await client.runs.create(
|
||||
thread["thread_id"],
|
||||
@@ -139,7 +137,7 @@ Now let's run a thread with the multitask parameter set to "rollback":
|
||||
--data "{
|
||||
\"assistant_id\": \"agent\",
|
||||
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what\'s the weather in sf?\"}]},
|
||||
}" && sleep 2 && curl --request POST \
|
||||
}" && curl --request POST \
|
||||
--url <DEPLOY<ENT_URL>>/threads/<THREAD_ID>/runs \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Quick Start
|
||||
# LangGraph Cloud Quick Start
|
||||
|
||||
In this tutorial you will build and deploy a simple chatbot agent that can look things up on the internet. You will be using [LangGraph Cloud](../concepts/langgraph_cloud.md), [LangGraph Studio](../concepts/langgraph_studio.md) to visualize and test it out, and [LangGraph SDK](./reference/sdk/python_sdk_ref.md) to interact with the deployed agent.
|
||||
|
||||
@@ -8,9 +8,9 @@ If you want to learn how to build an agent like this from scratch, take a look a
|
||||
|
||||
This tutorial will use:
|
||||
|
||||
- Anthropic for the LLM - sign up and get an API key [here](https://console.anthropic.com/)
|
||||
- Tavily for the search engine - sign up and get an API key [here](https://app.tavily.com/)
|
||||
- LangSmith for hosting - sign up and get an API key [here](https://smith.langchain.com/)
|
||||
- Anthropic for the LLM - sign up and get an API key [here](https://console.anthropic.com/).
|
||||
- Tavily for the search engine - sign up and get an API key [here](https://app.tavily.com/).
|
||||
- LangSmith for hosting - sign up and get an API key [here](https://smith.langchain.com/).
|
||||
|
||||
## Create and configure your app
|
||||
|
||||
|
||||
@@ -60,6 +60,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"content": {
|
||||
@@ -116,6 +126,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
@@ -2848,9 +2868,18 @@
|
||||
"description": "The cron schedule to execute this job on."
|
||||
},
|
||||
"assistant_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Assistant Id"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Assistant Id"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"title": "Graph Id"
|
||||
}
|
||||
],
|
||||
"description": "The assistant ID or graph name to run. If using graph name, will default to the assistant automatically created from that graph by the server."
|
||||
},
|
||||
"input": {
|
||||
"anyOf": [
|
||||
@@ -3151,6 +3180,66 @@
|
||||
],
|
||||
"title": "Run"
|
||||
},
|
||||
"Send": {
|
||||
"type": "object",
|
||||
"title": "Send",
|
||||
"description": "A message to send to a node.",
|
||||
"properties": {
|
||||
"node": {
|
||||
"type": "string",
|
||||
"title": "Node",
|
||||
"description": "The node to send the message to."
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
"title": "Message",
|
||||
"description": "The message to send."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"node",
|
||||
"input"
|
||||
]
|
||||
},
|
||||
"Command": {
|
||||
"type": "object",
|
||||
"title": "Command",
|
||||
"description": "The command to run.",
|
||||
"properties": {
|
||||
"update": {
|
||||
"type": "object",
|
||||
"title": "Update",
|
||||
"description": "An update to the state."
|
||||
},
|
||||
"resume": {
|
||||
"type": [
|
||||
"object",
|
||||
"array",
|
||||
"number",
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"title": "Resume",
|
||||
"description": "A value to pass to an interrupted node."
|
||||
},
|
||||
"send": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Send"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Send"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunCreateStateful": {
|
||||
"properties": {
|
||||
"assistant_id": {
|
||||
@@ -3176,13 +3265,19 @@
|
||||
"input": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Input",
|
||||
"description": "The input to the graph."
|
||||
},
|
||||
"command": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Command"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
@@ -3385,13 +3480,19 @@
|
||||
"input": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Input",
|
||||
"description": "The input to the graph."
|
||||
},
|
||||
"command": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Command"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
|
||||
@@ -53,7 +53,7 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Example:
|
||||
Example with environment variables:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -78,6 +78,37 @@ The base command for the LangGraph CLI is `langgraph`.
|
||||
langgraph [OPTIONS] COMMAND [ARGS]
|
||||
```
|
||||
|
||||
### `dev`
|
||||
|
||||
Run LangGraph API server in development mode with hot reloading and debugging capabilities. This lightweight server requires no Docker installation and is suitable for development and testing. State is persisted to a local directory.
|
||||
|
||||
**Installation**
|
||||
|
||||
This command requires the "inmem" extra to be installed:
|
||||
|
||||
```bash
|
||||
pip install -U "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
**Usage**
|
||||
|
||||
```
|
||||
langgraph dev [OPTIONS]
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
| Option | Default | Description |
|
||||
|----------------------------|------------------|--------------------------------------------------------------------------------------------|
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to configuration file declaring dependencies, graphs and environment variables |
|
||||
| `--host TEXT` | `127.0.0.1` | Host to bind the server to |
|
||||
| `--port INTEGER` | `2024` | Port to bind the server to |
|
||||
| `--no-reload` | | Disable auto-reload |
|
||||
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
|
||||
| `--no-browser` | | Disable automatic browser opening |
|
||||
| `--debug-port INTEGER` | | Port for debugger to listen on |
|
||||
| `--help` | | Display command documentation |
|
||||
|
||||
### `build`
|
||||
|
||||
Build LangGraph Cloud API server Docker image.
|
||||
@@ -100,7 +131,7 @@ langgraph build [OPTIONS]
|
||||
|
||||
### `up`
|
||||
|
||||
Start langgraph API server. For local testing, requires a LangSmith API key with access to LangGraph Cloud closed beta. Requires a license key for production use.
|
||||
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Cloud closed beta. Requires a license key for production use.
|
||||
|
||||
**Usage**
|
||||
|
||||
@@ -120,8 +151,8 @@ langgraph up [OPTIONS]
|
||||
| `--verbose` | | Show more output from the server logs. |
|
||||
| `-c, --config FILE` | `langgraph.json` | Path to configuration file declaring dependencies, graphs and environment variables. |
|
||||
| `-d, --docker-compose FILE` | | Path to docker-compose.yml file with additional services to launch. |
|
||||
| `-p, --port INTEGER` | `8123` | Port to expose. Example: `langgraph test --port 8000` |
|
||||
| `--pull / --no-pull` | `pull` | Pull latest images. Use --no-pull for running the server with locally-built images. Example: `langgraph up --no-pull` |
|
||||
| `-p, --port INTEGER` | `8123` | Port to expose. Example: `langgraph up --port 8000` |
|
||||
| `--pull / --no-pull` | `pull` | Pull latest images. Use `--no-pull` for running the server with locally-built images. Example: `langgraph up --no-pull` |
|
||||
| `--recreate / --no-recreate` | `no-recreate` | Recreate containers even if their configuration and image haven't changed |
|
||||
| `--help` | | Display command documentation. |
|
||||
|
||||
@@ -148,9 +179,9 @@ Example:
|
||||
langgraph dockerfile -c langgraph.json Dockerfile
|
||||
```
|
||||
|
||||
Would generate something like the following:
|
||||
This generates a Dockerfile that looks similar to:
|
||||
|
||||
```text
|
||||
```dockerfile
|
||||
FROM langchain/langgraph-api:3.11
|
||||
|
||||
ADD ./pipconf.txt /pipconfig.txt
|
||||
@@ -170,6 +201,3 @@ RUN set -ex && \
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph", "storm": "/deps/__outer_graphs/src/storm.py:graph"}'
|
||||
```
|
||||
|
||||
You can then customize, build images, push, and deploy from this file.
|
||||
|
||||
@@ -53,7 +53,7 @@ You’ll build a Docker image using the [LangGraph CLI](./langgraph_cli.md), whi
|
||||
For more information, please see:
|
||||
|
||||
* [Self-Hosted conceptual guide](./self_hosted.md)
|
||||
* [Self-Hosted Deployment how-to guide](../how-tos/deploy-self-hosted.md)
|
||||
* [Self-Hosted deployment how-to guide](../how-tos/deploy-self-hosted.md)
|
||||
|
||||
## Cloud SaaS
|
||||
|
||||
@@ -89,8 +89,8 @@ For more information please see:
|
||||
|
||||
## Related
|
||||
|
||||
For more information please see:
|
||||
For more information, please see:
|
||||
|
||||
* [LangGraph Platform Plans](./plans.md)
|
||||
* [LangGraph Platform Pricing](https://www.langchain.com/langgraph-platform-pricing)
|
||||
* [LangGraph Platform plans](./plans.md)
|
||||
* [LangGraph Platform pricing](https://www.langchain.com/langgraph-platform-pricing)
|
||||
* [Deployment how-to guides](../how-tos/index.md#deployment)
|
||||
|
||||
@@ -15,10 +15,12 @@ If you do not want to use LangGraph Platform, we describe the options we have im
|
||||

|
||||
|
||||
## Reject
|
||||
|
||||
This is the simplest option, this just rejects any follow up runs and does not allow double texting.
|
||||
See the [how-to guide](../cloud/how-tos/reject_concurrent.md) for configuring the reject double text option.
|
||||
|
||||
## Enqueue
|
||||
|
||||
This is a relatively simple option which continues the first run until it completes the whole run, then sends the new input as a separate run.
|
||||
See the [how-to guide](../cloud/how-tos/enqueue_concurrent.md) for configuring the enqueue double text option.
|
||||
|
||||
@@ -35,10 +37,6 @@ See the [how-to guide](../cloud/how-tos/interrupt_concurrent.md) for configuring
|
||||
|
||||
## Rollback
|
||||
|
||||
This option rolls back all work done up until that point.
|
||||
It then sends the user input in, basically as if it just followed the original run input.
|
||||
|
||||
This may create some weird states - for example, you may have two `User` messages in a row, with no `Asssitant` message in between them.
|
||||
You will need to make sure the LLM you are calling can handle that, or combine those into a single `User` message.
|
||||
This option interrupts the current execution AND rolls back all work done up until that point, including the original run input. It then sends the new user input in, basically as if it was the original input.
|
||||
|
||||
See the [how-to guide](../cloud/how-tos/rollback_concurrent.md) for configuring the rollback double text option.
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
LLMs are extremely powerful, particularly when connected to other systems such as a retriever or APIs. This is why many LLM applications use a control flow of steps before and / or after LLM calls. As an example [RAG](https://github.com/langchain-ai/rag-from-scratch) performs retrieval of relevant documents to a question, and passes those documents to an LLM in order to ground the response. Often a control flow of steps before and / or after an LLM is called a "chain." Chains are a popular paradigm for programming with LLMs and offer a high degree of reliability; the same set of steps runs with each chain invocation.
|
||||
|
||||
However, we often want LLM systems that can pick their own control flow! This is one definition of an [agent](https://blog.langchain.dev/what-is-an-agent/): an agent is a system that uses an LLM to decide the control flow of an application. Unlike a chain, an agent given an LLM some degree of control over the sequence of steps in the application. Examples of using an LLM to decide the control of an application:
|
||||
However, we often want LLM systems that can pick their own control flow! This is one definition of an [agent](https://blog.langchain.dev/what-is-an-agent/): an agent is a system that uses an LLM to decide the control flow of an application. Unlike a chain, an agent gives an LLM some degree of control over the sequence of steps in the application. Examples of using an LLM to decide the control of an application:
|
||||
|
||||
- Using an LLM to route between two potential paths
|
||||
- Using an LLM to decide which of many tools to call
|
||||
- Using an LLM to decide whether the generated answer is sufficient or more work is need
|
||||
|
||||
There are many different types of [agent architectures](https://blog.langchain.dev/what-is-a-cognitive-architecture/) to consider, which given an LLM varying levels of control. On one extreme, a router allows an LLM to select a single step from a specified set of options and, on the other extreme, a fully autonomous long-running agent may have complete freedom to select any sequence of steps that it wants for a given problem.
|
||||
There are many different types of [agent architectures](https://blog.langchain.dev/what-is-a-cognitive-architecture/) to consider, which give an LLM varying levels of control. On one extreme, a router allows an LLM to select a single step from a specified set of options and, on the other extreme, a fully autonomous long-running agent may have complete freedom to select any sequence of steps that it wants for a given problem.
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 141 KiB |
@@ -30,7 +30,7 @@ The conceptual guide does not cover step-by-step instructions or specific implem
|
||||
- [Streaming](streaming.md): Streaming is crucial for enhancing the responsiveness of applications built on LLMs. By displaying output progressively, even before a complete response is ready, streaming significantly improves user experience (UX), particularly when dealing with the latency of LLMs.
|
||||
- [FAQ](faq.md): Frequently asked questions about LangGraph.
|
||||
|
||||
## LangGraph Platform
|
||||
## LangGraph Platform
|
||||
|
||||
LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework.
|
||||
|
||||
@@ -42,14 +42,14 @@ The LangGraph Platform offers a few different deployment options described in th
|
||||
* LangGraph is an MIT-licensed open-source library, which we are committed to maintaining and growing for the community.
|
||||
* You can always deploy LangGraph applications on your own infrastructure using the open-source LangGraph project without using LangGraph Platform.
|
||||
|
||||
**High Level**
|
||||
### High Level
|
||||
|
||||
- [Why LangGraph Platform?](./langgraph_platform.md): The LangGraph platform is an opinionated way to deploy and manage LangGraph applications. This guide provides an overview of the key features and concepts behind LangGraph Platform.
|
||||
- [Deployment Options](./deployment_options.md): LangGraph Platform offers four deployment options: [Self-Hosted Lite](./self_hosted.md#self-hosted-lite), [Self-Hosted Enterprise](./self_hosted.md#self-hosted-enterprise), [bring your own cloud (BYOC)](./bring_your_own_cloud.md), and [Cloud SaaS](./langgraph_cloud.md). This guide explains the differences between these options, and which Plans they are available on.
|
||||
- [Plans](./plans.md): LangGraph Platforms offer three different plans: Developer, Plus, Enterprise. This guide explains the differences between these options, what deployment options are available for each, and how to sign up for each one.
|
||||
- [Template Applications](./template_applications.md): Reference applications designed to help you get started quickly when building with LangGraph.
|
||||
|
||||
**Components**
|
||||
### Components
|
||||
|
||||
The LangGraph Platform comprises several components that work together to support the deployment and management of LangGraph applications:
|
||||
|
||||
@@ -59,7 +59,7 @@ The LangGraph Platform comprises several components that work together to suppor
|
||||
- [Python/JS SDK](./sdk.md): The Python/JS SDK provides a programmatic way to interact with deployed LangGraph Applications.
|
||||
- [Remote Graph](../how-tos/use-remote-graph.md): A RemoteGraph allows you to interact with any deployed LangGraph application as though it were running locally.
|
||||
|
||||
**LangGraph Server**
|
||||
### LangGraph Server
|
||||
|
||||
- [Application Structure](./application_structure.md): A LangGraph application consists of one or more graphs, a LangGraph API Configuration file (`langgraph.json`), a file that specifies dependencies, and environment variables.
|
||||
- [Assistants](./assistants.md): Assistants are a way to save and manage different configurations of your LangGraph applications.
|
||||
@@ -67,7 +67,7 @@ The LangGraph Platform comprises several components that work together to suppor
|
||||
- [Cron Jobs](./langgraph_server.md#cron-jobs): Cron jobs are a way to schedule tasks to run at specific times in your LangGraph application.
|
||||
- [Double Texting](./double_texting.md): Double texting is a common issue in LLM applications where users may send multiple messages before the graph has finished running. This guide explains how to handle double texting with LangGraph Deploy.
|
||||
|
||||
**Deployment Options**
|
||||
### Deployment Options
|
||||
|
||||
|
||||
- [Self-Hosted Lite](./self_hosted.md): A free (up to 1 million nodes executed), limited version of LangGraph Platform that you can run locally or in a self-hosted manner
|
||||
|
||||
@@ -28,9 +28,28 @@ The CLI provides the following core functionality:
|
||||
|
||||
The `langgraph build` command builds a Docker image for the [LangGraph API server](./langgraph_server.md) that can be directly deployed.
|
||||
|
||||
### `dev`
|
||||
|
||||
!!! note "New in version 0.1.55"
|
||||
The `langgraph dev` command was introduced in langgraph-cli version 0.1.55.
|
||||
|
||||
The `langgraph dev` command starts a lightweight development server that requires no Docker installation. This server is ideal for rapid development and testing, with features like:
|
||||
|
||||
- Hot reloading: Changes to your code are automatically detected and reloaded
|
||||
- Debugger support: Attach your IDE's debugger for line-by-line debugging
|
||||
- In-memory state with local persistence: Server state is stored in memory for speed but persisted locally between restarts
|
||||
|
||||
To use this command, you need to install the CLI with the "inmem" extra:
|
||||
|
||||
```bash
|
||||
pip install -U "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
**Note**: This command is intended for local development and testing only. It is not recommended for production use. Since it does not use Docker, we recommend using virtual environments to manage your project's dependencies.
|
||||
|
||||
### `up`
|
||||
|
||||
The `langgraph up` command starts an instance of the [LangGraph API server](./langgraph_server.md) locally. This requires docker to be installed and running locally. It also requires a LangSmith API key for local development or a license key for production use.
|
||||
The `langgraph up` command starts an instance of the [LangGraph API server](./langgraph_server.md) locally in a docker container. This requires thedocker server to be running locally. It also requires a LangSmith API key for local development or a license key for production use.
|
||||
|
||||
The server includes all API endpoints for your graph's runs, threads, assistants, etc. as well as the other services required to run your agent, including a managed database for checkpointing and storage.
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Cloud SaaS
|
||||
|
||||
!!! info "Prerequisites"
|
||||
- [LangGraph Platform](./langgraph_platform.md)
|
||||
!!! info "Prerequisites"
|
||||
- [LangGraph Platform](./langgraph_platform.md)
|
||||
- [LangGraph Server](./langgraph_server.md)
|
||||
|
||||
## Overview
|
||||
|
||||
LangGraph's Cloud SaaS is a managed service that provides a scalable and secure environment for deploying LangGraph APIs. It is designed to work seamlessly with your LangGraph API regardless of how it is defined, what tools it uses, or any dependencies. Cloud SaaS provides a simple way to deploy and manage your LangGraph API in the cloud.
|
||||
LangGraph's Cloud SaaS is a managed service for deploying LangGraph APIs, regardless of its definition or dependencies. The service offers managed implementations of checkpointers and stores, allowing you to focus on building the right cognitive architecture for your use case. By handling scalable & secure infrastructure, LangGraph Cloud offers the fastest path to getting your LangGraph API deployed to production.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -14,6 +14,13 @@ A **deployment** is an instance of a LangGraph API. A single deployment can have
|
||||
|
||||
See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for creating a new deployment.
|
||||
|
||||
## Resource Allocation
|
||||
|
||||
| **Deployment Type** | **CPU** | **Memory** | **Scaling** |
|
||||
|---------------------|---------|------------|---------------------|
|
||||
| Development | 1 CPU | 1 GB | Up to 1 container |
|
||||
| Production | 1 CPU | 2 GB | Up to 10 containers |
|
||||
|
||||
## Revision
|
||||
|
||||
A revision is an iteration of a [deployment](#deployment). When a new deployment is created, an initial revision is automatically created. To deploy new code changes or update environment variable configurations for a deployment, a new revision must be created. When a revision is created, a new container image is built automatically.
|
||||
@@ -33,6 +40,7 @@ A high-level diagram of a Cloud SaaS deployment.
|
||||
|
||||

|
||||
|
||||
|
||||
## Related
|
||||
|
||||
- [Deployment Options](./deployment_options.md)
|
||||
- [Deployment Options](./deployment_options.md)
|
||||
|
||||
@@ -35,6 +35,16 @@ While in Beta, LangGraph Studio is available for free to all [LangSmith](https:/
|
||||
|
||||
If you have deployed your LangGraph application on LangGraph Platform (Cloud), you can access the studio as part of that
|
||||
|
||||
### Development server
|
||||
|
||||
LangGraph CLI also contains a command for running an in-memory development server that can be used to connect a local LangGraph app with the studio.
|
||||
See [instructions here](../cloud/reference/cli.md#dev) for more information.
|
||||
|
||||
The way this works is that it runs inside your local environment.
|
||||
It will spin up an in-memory, development server to deploy the graph.
|
||||
You can then connect to the studio via the Cloud hosted version of LangGraph Platform.
|
||||
To be clear, the web studio will connect to your locally running server - your agent is still running locally and never leaves your device.
|
||||
|
||||
## Studio FAQs
|
||||
|
||||
### Why is my project failing to start?
|
||||
|
||||
@@ -391,7 +391,7 @@ Read [this how-to](https://langchain-ai.github.io/langgraph/how-tos/recursion-li
|
||||
|
||||
It can often be useful to set breakpoints before or after certain nodes execute. This can be used to wait for human approval before continuing. These can be set when you ["compile" a graph](#compiling-your-graph). You can set breakpoints either _before_ a node executes (using `interrupt_before`) or after a node executes (using `interrupt_after`.)
|
||||
|
||||
You **MUST** use a [checkpoiner](./persistence.md) when using breakpoints. This is because your graph needs to be able to resume execution.
|
||||
You **MUST** use a [checkpointer](./persistence.md) when using breakpoints. This is because your graph needs to be able to resume execution.
|
||||
|
||||
In order to resume execution, you can just invoke your graph with `None` as the input.
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ You must pass these when invoking the graph as part of the `configurable` portio
|
||||
# {"configurable": {"thread_id": "1", "checkpoint_id": "0c62ca34-ac19-445d-bbb0-5b4984975b2a"}} # also valid config
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke(inputs, config=config)
|
||||
graph.invoke(None, config=config)
|
||||
```
|
||||
|
||||
Importantly, LangGraph knows whether a particular checkpoint has been executed previously. If it has, LangGraph simply *re-plays* that particular step in the graph and does not re-execute the step. See this [how to guide on time-travel to learn more about replaying](../how-tos/human_in_the_loop/time-travel.ipynb).
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## Versions
|
||||
|
||||
There are two versions of the self hosted deployment: [Self-Hosted Enterprise](./deployment_options.md#self-hosted-enterprise) and [Self-Hosted Lite](./deployment_options.md#self-hosted-lite).
|
||||
There are two versions of the self-hosted deployment: [Self-Hosted Enterprise](./deployment_options.md#self-hosted-enterprise) and [Self-Hosted Lite](./deployment_options.md#self-hosted-lite).
|
||||
|
||||
### Self-Hosted Lite
|
||||
|
||||
@@ -29,7 +29,15 @@ To use the Self-Hosted Enterprise version, you must acquire a license key that y
|
||||
## How it works
|
||||
|
||||
- Deploy Redis and Postgres instances on your own infrastructure.
|
||||
- Build the docker image for [LangGraph Server](./langgraph_server.md) using the [LangGraph CLI](./langgraph_cli.md)
|
||||
- Build the docker image for [LangGraph Server](./langgraph_server.md) using the [LangGraph CLI](./langgraph_cli.md).
|
||||
- Deploy a web server that will run the docker image and pass in the necessary environment variables.
|
||||
|
||||
See the [how-to guide](../how-tos/deploy-self-hosted.md)
|
||||
For step-by-step instructions, see [How to set up a self-hosted deployment of LangGraph](../how-tos/deploy-self-hosted.md).
|
||||
|
||||
## Helm Chart
|
||||
|
||||
If you would like to deploy LangGraph Cloud on Kubernetes, you can use this [Helm chart](https://github.com/langchain-ai/helm/blob/main/charts/langgraph-cloud/README.md).
|
||||
|
||||
## Related
|
||||
|
||||
- [How to set up a self-hosted deployment of LangGraph](../how-tos/deploy-self-hosted.md).
|
||||
|
||||
@@ -6,22 +6,14 @@
|
||||
|
||||
Templates are open source reference applications designed to help you get started quickly when building with LangGraph. They provide working examples of common agentic workflows that can be customized to your needs.
|
||||
|
||||
Templates can be accessed via [LangGraph Studio](langgraph_studio.md), or cloned directly from Github. You can download LangGraph Studio and see available templates [here](https://studio.langchain.com/).
|
||||
Templates can be accessed via [LangGraph Studio (macOS only)](langgraph_studio.md), or cloned directly from Github. You can download LangGraph Studio and see available templates [here](https://studio.langchain.com/).
|
||||
|
||||
## Available templates
|
||||
|
||||
- **New LangGraph Project**: A simple, minimal chatbot with memory.
|
||||
- [Python](https://github.com/langchain-ai/new-langgraph-project)
|
||||
- [JS/TS](https://github.com/langchain-ai/new-langgraphjs-project)
|
||||
- **ReAct Agent**: A simple agent that can be flexibly extended to many tools.
|
||||
- [Python](https://github.com/langchain-ai/react-agent)
|
||||
- [JS/TS](https://github.com/langchain-ai/react-agent-js)
|
||||
- **Memory Agent**: A ReAct-style agent with an additional tool to store memories for use across conversational threads.
|
||||
- [Python](https://github.com/langchain-ai/memory-agent)
|
||||
- [JS/TS](https://github.com/langchain-ai/memory-agent-js)
|
||||
- **Retrieval Agent**: An agent that includes a retrieval-based question-answering system.
|
||||
- [Python](https://github.com/langchain-ai/retrieval-agent-template)
|
||||
- [JS/TS](https://github.com/langchain-ai/retrieval-agent-template-js)
|
||||
- **Data-enrichment Agent**: An agent that performs web searches and organizes its findings into a structured format.
|
||||
- [Python](https://github.com/langchain-ai/data-enrichment)
|
||||
- [JS/TS](https://github.com/langchain-ai/data-enrichment-js)
|
||||
| Template | Description | Python | JS/TS |
|
||||
|---------------------------|------------------------------------------------------------------------------------------|------------------------------------------------------------------|---------------------------------------------------------------------|
|
||||
| **New LangGraph Project** | A simple, minimal chatbot with memory. | [Repo](https://github.com/langchain-ai/new-langgraph-project) | [Repo](https://github.com/langchain-ai/new-langgraphjs-project) |
|
||||
| **ReAct Agent** | A simple agent that can be flexibly extended to many tools. | [Repo](https://github.com/langchain-ai/react-agent) | [Repo](https://github.com/langchain-ai/react-agent-js) |
|
||||
| **Memory Agent** | A ReAct-style agent with an additional tool to store memories for use across threads. | [Repo](https://github.com/langchain-ai/memory-agent) | [Repo](https://github.com/langchain-ai/memory-agent-js) |
|
||||
| **Retrieval Agent** | An agent that includes a retrieval-based question-answering system. | [Repo](https://github.com/langchain-ai/retrieval-agent-template) | [Repo](https://github.com/langchain-ai/retrieval-agent-template-js) |
|
||||
| **Data-Enrichment Agent** | An agent that performs web searches and organizes its findings into a structured format. | [Repo](https://github.com/langchain-ai/data-enrichment) | [Repo](https://github.com/langchain-ai/data-enrichment-js) |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8381b6e0-29a6-48c5-b451-5d2549351249",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# How to use LangGraph Platform to deploy CrewAI, AutoGen, and other frameworks\n",
|
||||
"\n",
|
||||
"[LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) provides infrastructure for deploying agents. This integrates seamlessly with LangGraph, but can also work with other frameworks. The way to make this work is to wrap the agent in a single LangGraph node, and have that be the entire graph.\n",
|
||||
"\n",
|
||||
"Doing so will allow you to deploy to LangGraph Platform, and allows you to get a lot of the [benefits](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/). You get horizontally scalable infrastructure, a task queue to handle bursty operations, a persistence layer to power short term memory, and long term memory support.\n",
|
||||
"\n",
|
||||
"In this guide we show how to do this with an AutoGen agent, but this method should work for agents defined in other frameworks like CrewAI, LlamaIndex, and others as well."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1113cb16-b538-448c-924c-85731ce96ebd",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "f05993fa-9d03-4f45-bc13-0a8d87260d86",
|
||||
"metadata": {
|
||||
"scrolled": true
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%pip install autogen langgraph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "f4e0ca12-1714-4776-a30a-9527e519799b",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import getpass\n",
|
||||
"import os\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def _set_env(var: str):\n",
|
||||
" if not os.environ.get(var):\n",
|
||||
" os.environ[var] = getpass.getpass(f\"{var}: \")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"_set_env(\"OPENAI_API_KEY\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1926bbc3-6b06-41e0-9604-860a2bbf8fa3",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Define autogen agent\n",
|
||||
"\n",
|
||||
"Here we define our AutoGen agent. From https://github.com/microsoft/autogen/blob/0.2/notebook/agentchat_web_info.ipynb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d4a14dc7-d565-4207-8788-525f85b9fb27",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import autogen\n",
|
||||
"import os\n",
|
||||
"\n",
|
||||
"config_list = [{\"model\": \"gpt-4o\", \"api_key\": os.environ[\"OPENAI_API_KEY\"]}]\n",
|
||||
"\n",
|
||||
"llm_config = {\n",
|
||||
" \"timeout\": 600,\n",
|
||||
" \"cache_seed\": 42,\n",
|
||||
" \"config_list\": config_list,\n",
|
||||
" \"temperature\": 0,\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"autogen_agent = autogen.AssistantAgent(\n",
|
||||
" name=\"assistant\",\n",
|
||||
" llm_config=llm_config,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"user_proxy = autogen.UserProxyAgent(\n",
|
||||
" name=\"user_proxy\",\n",
|
||||
" human_input_mode=\"NEVER\",\n",
|
||||
" max_consecutive_auto_reply=10,\n",
|
||||
" is_termination_msg=lambda x: x.get(\"content\", \"\").rstrip().endswith(\"TERMINATE\"),\n",
|
||||
" code_execution_config={\n",
|
||||
" \"work_dir\": \"web\",\n",
|
||||
" \"use_docker\": False,\n",
|
||||
" }, # Please set use_docker=True if docker is available to run the generated code. Using docker is safer than running the generated code directly.\n",
|
||||
" llm_config=llm_config,\n",
|
||||
" system_message=\"Reply TERMINATE if the task has been solved at full satisfaction. Otherwise, reply CONTINUE, or the reason why the task is not solved yet.\",\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b1170836-f23e-4e4c-ab83-ce791cd7fbd2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Wrap in LangGraph\n",
|
||||
"\n",
|
||||
"We now wrap the AutoGen agent in a single LangGraph node, and make that the entire graph.\n",
|
||||
"The main thing this involves is defining an Input and Output schema for the node, which you would need to do if deploying this manually, so it's no extra work"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "7b417c16-ff4e-4d5c-a9a9-0aaeeef6ede5",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from langgraph.graph import StateGraph, MessagesState\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def call_autogen_agent(state: MessagesState):\n",
|
||||
" last_message = state[\"messages\"][-1]\n",
|
||||
" response = user_proxy.initiate_chat(autogen_agent, message=last_message.content)\n",
|
||||
" # get the final response from the agent\n",
|
||||
" content = response.chat_history[-1][\"content\"]\n",
|
||||
" return {\"messages\": {\"role\": \"assistant\", \"content\": content}}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"graph = StateGraph(MessagesState)\n",
|
||||
"graph.add_node(call_autogen_agent)\n",
|
||||
"graph.set_entry_point(\"call_autogen_agent\")\n",
|
||||
"graph = graph.compile()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f6a18377-ac29-478f-a76a-b213f1a3c85d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Deploy with LangGraph Platform\n",
|
||||
"\n",
|
||||
"You can now deploy this as you normally would with LangGraph Platform. See [these instructions](https://langchain-ai.github.io/langgraph/concepts/deployment_options/) for more details."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -345,7 +345,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.4"
|
||||
"version": "3.11.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
@@ -17,14 +17,18 @@ You will need to do the following:
|
||||
2. Build a docker image with the [LangGraph Server](../concepts/langgraph_server.md) using the [LangGraph CLI](../concepts/langgraph_cli.md).
|
||||
3. Deploy a web server that will run the docker image and pass in the necessary environment variables.
|
||||
|
||||
## Helm Chart
|
||||
|
||||
If you would like to deploy LangGraph Cloud on Kubernetes, you can use this [Helm chart](https://github.com/langchain-ai/helm/blob/main/charts/langgraph-cloud/README.md).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
You will eventually need to pass in the following environment variables to the LangGraph Deploy server:
|
||||
|
||||
- `REDIS_URI`: Connection details to a Redis instance. Redis will be used as a pub-sub broker to enable streaming real time output from background runs.
|
||||
- `DATABASE_URI`: Postgres connection details. Postgres will be used to store assistants, threads, runs, persist thread state and long term memory, and to manage the state of the background task queue with 'exactly once' semantics.
|
||||
- `LANGSMITH_API_KEY`: (If using [Self-Hosted Lite]) LangSmith API key. This will be used to authenticate ONCE at server start up.
|
||||
- `LANGGRAPH_CLOUD_LICENSE_KEY`: (If using Self-Hosted Enterprise) LangGraph Platform license key. This will be used to authenticate ONCE at server start up.
|
||||
- `LANGSMITH_API_KEY`: (If using [Self-Hosted Lite](../concepts/deployment_options.md#self-hosted-lite)) LangSmith API key. This will be used to authenticate ONCE at server start up.
|
||||
- `LANGGRAPH_CLOUD_LICENSE_KEY`: (If using [Self-Hosted Enterprise](../concepts/deployment_options.md#self-hosted-enterprise)) LangGraph Platform license key. This will be used to authenticate ONCE at server start up.
|
||||
|
||||
|
||||
## Build the Docker Image
|
||||
@@ -70,7 +74,7 @@ If you want to run this quickly without setting up a separate Redis and Postgres
|
||||
* You need to replace `my-image` with the name of the image you built in the previous step (from `langgraph build`).
|
||||
and you should provide appropriate values for `REDIS_URI`, `DATABASE_URI`, and `LANGSMITH_API_KEY`.
|
||||
* If your application requires additional environment variables, you can pass them in a similar way.
|
||||
* If using Self-Hosted Enterprise, you must provide `LANGGRAPH_CLOUD_LICENSE_KEY` as an additional environment variable.
|
||||
* If using [Self-Hosted Enterprise](../concepts/deployment_options.md#self-hosted-enterprise), you must provide `LANGGRAPH_CLOUD_LICENSE_KEY` as an additional environment variable.
|
||||
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
@@ -40,7 +40,7 @@ LangGraph makes it easy to manage conversation [memory](../concepts/memory.md) i
|
||||
- [How to delete messages](memory/delete-messages.ipynb)
|
||||
- [How to add summary conversation memory](memory/add-summary-conversation-history.ipynb)
|
||||
|
||||
### Human in the Loop
|
||||
### Human-in-the-loop
|
||||
|
||||
[Human-in-the-loop](../concepts/human_in_the_loop.md) functionality allows
|
||||
you to involve humans in the decision-making process of your graph. These how-to guides show how to implement human-in-the-loop workflows in your graph.
|
||||
@@ -99,11 +99,11 @@ These how-to guides show common patterns for tool calling with LangGraph:
|
||||
- [How to run graph asynchronously](async.ipynb)
|
||||
- [How to visualize your graph](visualization.ipynb)
|
||||
- [How to add runtime configuration to your graph](configuration.ipynb)
|
||||
- [How to use a Pydantic model as your state](state-model.ipynb)
|
||||
- [How to add node retries](node-retries.ipynb)
|
||||
- [How to force function calling agent to structure output](react-agent-structured-output.ipynb)
|
||||
- [How to pass custom LangSmith run ID for graph runs](run-id-langsmith.ipynb)
|
||||
- [How to return state before hitting recursion limit](return-when-recursion-limit-hits.ipynb)
|
||||
- [How to integrate LangGraph with AutoGen, CrewAI, and other frameworks](autogen-integration.ipynb)
|
||||
|
||||
### Prebuilt ReAct Agent
|
||||
|
||||
@@ -142,6 +142,7 @@ Learn how to set up your app for deployment to LangGraph Platform:
|
||||
- [How to customize Dockerfile](../cloud/deployment/custom_docker.md)
|
||||
- [How to test locally](../cloud/deployment/test_locally.md)
|
||||
- [How to rebuild graph at runtime](../cloud/deployment/graph_rebuild.md)
|
||||
- [How to use LangGraph Platform to deploy CrewAI, AutoGen, and other frameworks](autogen-langgraph-platform.ipynb)
|
||||
|
||||
### Deployment
|
||||
|
||||
@@ -150,6 +151,7 @@ LangGraph applications can be deployed using LangGraph Cloud, which provides a r
|
||||
- [How to deploy to LangGraph cloud](../cloud/deployment/cloud.md)
|
||||
- [How to deploy to a self-hosted environment](./deploy-self-hosted.md)
|
||||
- [How to interact with the deployment using RemoteGraph](./use-remote-graph.md)
|
||||
|
||||
### Assistants
|
||||
|
||||
[Assistants](../concepts/assistants.md) is a configured instance of a template.
|
||||
@@ -164,7 +166,7 @@ LangGraph applications can be deployed using LangGraph Cloud, which provides a r
|
||||
|
||||
### Runs
|
||||
|
||||
LangGraph Cloud supports multiple types of runs besides streaming runs.
|
||||
LangGraph Platform supports multiple types of runs besides streaming runs.
|
||||
|
||||
- [How to run an agent in the background](../cloud/how-tos/background_run.md)
|
||||
- [How to run multiple agents in the same thread](../cloud/how-tos/same-thread.md)
|
||||
@@ -184,7 +186,7 @@ Streaming the results of your LLM application is vital for ensuring a good user
|
||||
|
||||
### Human-in-the-loop
|
||||
|
||||
When creating complex graphs, leaving every decision up to the LLM can be dangerous, especially when the decisions involve invoking certain tools or accessing specific documents. To remedy this, LangGraph allows you to insert human-in-the-loop behavior to ensure your graph does not have undesired outcomes. Read more about the different ways you can add human-in-the-loop capabilities to your LangGraph Cloud projects in these how-to guides:
|
||||
When designing complex graphs, relying entirely on the LLM for decision-making can be risky, particularly when it involves tools that interact with files, APIs, or databases. These interactions may lead to unintended data access or modifications, depending on the use case. To mitigate these risks, LangGraph allows you to integrate human-in-the-loop behavior, ensuring your LLM applications operate as intended without undesirable outcomes.
|
||||
|
||||
- [How to add a breakpoint](../cloud/how-tos/human_in_the_loop_breakpoint.md)
|
||||
- [How to wait for user input](../cloud/how-tos/human_in_the_loop_user_input.md)
|
||||
@@ -194,7 +196,7 @@ When creating complex graphs, leaving every decision up to the LLM can be danger
|
||||
|
||||
### Double-texting
|
||||
|
||||
Graph execution can take a while, and sometimes users may change their mind about the input they wanted to send before their original input has finished running. For example, a user might notice a typo in their original request and will edit the prompt and resend it. Deciding what to do in these cases is important for ensuring a smooth user experience and preventing your graphs from behaving in unexpected ways. The following how-to guides provide information on the various options LangGraph Cloud gives you for dealing with double-texting:
|
||||
Graph execution can take a while, and sometimes users may change their mind about the input they wanted to send before their original input has finished running. For example, a user might notice a typo in their original request and will edit the prompt and resend it. Deciding what to do in these cases is important for ensuring a smooth user experience and preventing your graphs from behaving in unexpected ways.
|
||||
|
||||
- [How to use the interrupt option](../cloud/how-tos/interrupt_concurrent.md)
|
||||
- [How to use the rollback option](../cloud/how-tos/rollback_concurrent.md)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# How to connect a local agent to LangGraph Studio
|
||||
|
||||
This guide shows you how to connect your local agent to [LangGraph Studio](../concepts/langgraph_studio.md) for visualization, interaction, and debugging.
|
||||
|
||||
## Connection Options
|
||||
|
||||
There are two ways to connect your local agent to LangGraph Studio:
|
||||
|
||||
- [LangGraph Desktop](../concepts/langgraph_studio.md#desktop-app): Application, Mac only, requires Docker
|
||||
- [Development Server](../concepts/langgraph_studio.md#dev-server): Python package, all platforms, no Docker
|
||||
|
||||
In this guide we will cover how to use the development server as that is generally an easier and better experience.
|
||||
|
||||
## Setup your application
|
||||
|
||||
First, you will need to setup your application in the proper format.
|
||||
This means defining a `langgraph.json` file which contains paths to your agent(s).
|
||||
See [this guide](../concepts/application_structure.md) for information on how to do so.
|
||||
|
||||
## Install langgraph-cli
|
||||
|
||||
You will need to install [`langgraph-cli`](../cloud/reference/cli.md#langgraph-cli) (version `0.1.55` or higher).
|
||||
You will need to make sure to install the `inmem` extras.
|
||||
|
||||
```shell
|
||||
pip install "langgraph-cli[inmem]==0.1.55"
|
||||
```
|
||||
|
||||
## Run the development server
|
||||
|
||||
1. Navigate to your project directory (where `langgraph.json` is located)
|
||||
|
||||
2. Start the server:
|
||||
```bash
|
||||
langgraph dev
|
||||
```
|
||||
|
||||
This will look for the `langgraph.json` file in your current directory.
|
||||
In there, it will find the paths to the graph(s), and start those up.
|
||||
It will then automatically connect to the cloud-hosted studio.
|
||||
|
||||
## Use the studio
|
||||
|
||||
After connecting to the studio, a browser window should automatically pop up.
|
||||
This will use the cloud hosted studio UI to connect to your local development server.
|
||||
Your graph is still running locally, the UI is connecting to visualizing the agent and threads that are defined locally.
|
||||
|
||||
The graph will always use the most up-to-date code, so you will be able to change the underlying code and have it automatically reflected in the studio.
|
||||
This is useful for debugging workflows.
|
||||
You can run your graph in the UI until it messes up, go in and change your code, and then rerun from the node that failed.
|
||||
|
||||
# (Optional) Attach a debugger
|
||||
|
||||
For step-by-step debugging with breakpoints and variable inspection:
|
||||
|
||||
```bash
|
||||
# Install debugpy package
|
||||
pip install debugpy
|
||||
|
||||
# Start server with debugging enabled
|
||||
langgraph dev --debug-port 5678
|
||||
```
|
||||
|
||||
Then attach your preferred debugger:
|
||||
|
||||
=== "VS Code"
|
||||
Add this configuration to `launch.json`:
|
||||
```json
|
||||
{
|
||||
"name": "Attach to LangGraph",
|
||||
"type": "debugpy",
|
||||
"request": "attach",
|
||||
"connect": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 5678
|
||||
}
|
||||
}
|
||||
```
|
||||
Specify the port number you chose in the previous step.
|
||||
|
||||
=== "PyCharm"
|
||||
1. Go to Run → Edit Configurations
|
||||
2. Click + and select "Python Debug Server"
|
||||
3. Set IDE host name: `localhost`
|
||||
4. Set port: `5678` (or the port number you chose in the previous step)
|
||||
5. Click "OK" and start debugging
|
||||
@@ -3,6 +3,13 @@ title: Reference
|
||||
description: API reference for LangGraph
|
||||
---
|
||||
|
||||
<style>
|
||||
.md-sidebar {
|
||||
display: block !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
# Reference
|
||||
|
||||
Welcome to the LangGraph API reference! This reference provides detailed information about the LangGraph API, including classes, methods, and other components.
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"from langchain_community.document_loaders.recursive_url_loader import RecursiveUrlLoader\n",
|
||||
"\n",
|
||||
"# LCEL docs\n",
|
||||
"url = \"https://python.langchain.com/docs/concepts/#langchain-expression-language-lcel\"\n",
|
||||
"url = \"https://python.langchain.com/docs/concepts/lcel/\"\n",
|
||||
"loader = RecursiveUrlLoader(\n",
|
||||
" url=url, max_depth=20, extractor=lambda x: Soup(x, \"html.parser\").text\n",
|
||||
")\n",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%%capture --no-stderr\n",
|
||||
"% pip install -U langgraph langchain-community langchain-anthropic tavily-python pandas"
|
||||
"%pip install -U langgraph langchain-community langchain-anthropic tavily-python pandas openai"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -55,6 +55,7 @@
|
||||
"\n",
|
||||
"\n",
|
||||
"_set_env(\"ANTHROPIC_API_KEY\")\n",
|
||||
"_set_env(\"OPENAI_API_KEY\")\n",
|
||||
"_set_env(\"TAVILY_API_KEY\")"
|
||||
]
|
||||
},
|
||||
@@ -85,7 +86,9 @@
|
||||
"cell_type": "code",
|
||||
"execution_count": 21,
|
||||
"id": "71638c2a-5038-439e-907a-de2bb548db34",
|
||||
"metadata": {"hide_from_vcr": true},
|
||||
"metadata": {
|
||||
"hide_from_vcr": true
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import os\n",
|
||||
@@ -176,7 +179,9 @@
|
||||
"cell_type": "code",
|
||||
"execution_count": 22,
|
||||
"id": "654e2f81",
|
||||
"metadata": {"hide_from_vcr": true},
|
||||
"metadata": {
|
||||
"hide_from_vcr": true
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import re\n",
|
||||
|
||||
@@ -6,25 +6,23 @@ title: Tutorials
|
||||
|
||||
# Tutorials
|
||||
|
||||
Welcome to the LangGraph Tutorials! These notebooks introduce LangGraph through building various language agents and applications.
|
||||
New to LangGraph or LLM app development? Read this material to get up and running building your first applications.
|
||||
|
||||
## Quick Start
|
||||
## Get Started 🚀 {#quick-start}
|
||||
|
||||
Learn the basics of LangGraph through a comprehensive quick start in which you will build an agent from scratch.
|
||||
- [LangGraph Quickstart](introduction.ipynb): Build a chatbot that can use tools and keep track of conversation history. Add human-in-the-loop capabilities and explore how time-travel works.
|
||||
- [LangGraph Server Quickstart](langgraph-platform/local-server.md): Launch a LangGraph server locally and interact with it using the REST API and LangGraph Studio Web UI.
|
||||
- [LangGraph Cloud QuickStart](../cloud/quick_start.md): Deploy a LangGraph app using LangGraph Cloud.
|
||||
|
||||
- [Quick Start](introduction.ipynb): In this tutorial, you will build a support chatbot using LangGraph.
|
||||
- [LangGraph Cloud Quick Start](../cloud/quick_start.md): In this tutorial, you will build and deploy an agent to LangGraph Cloud.
|
||||
## Use cases 🛠️
|
||||
|
||||
## Use cases
|
||||
|
||||
Learn from example implementations of graphs designed for specific scenarios and that implement common design patterns.
|
||||
Explore practical implementations tailored for specific scenarios:
|
||||
|
||||
### Chatbots
|
||||
|
||||
- [Customer Support](customer-support/customer-support.ipynb): Build a customer support chatbot to manage flights, hotel reservations, car rentals, and other tasks
|
||||
- [Prompt Generation from User Requirements](chatbots/information-gather-prompting.ipynb): Build an information gathering chatbot
|
||||
- [Code Assistant](code_assistant/langgraph_code_assistant.ipynb): Build a code analysis and generation assistant
|
||||
|
||||
- [Customer Support](customer-support/customer-support.ipynb): Build a multi-functional support bot for flights, hotels, and car rentals.
|
||||
- [Prompt Generation from User Requirements](chatbots/information-gather-prompting.ipynb): Build an information gathering chatbot.
|
||||
- [Code Assistant](code_assistant/langgraph_code_assistant.ipynb): Build a code analysis and generation assistant.
|
||||
|
||||
### RAG
|
||||
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
"id": "4a1aae78-88a6-4133-b905-7e46c8e3772f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Quick Start\n",
|
||||
"# 🚀 LangGraph Quick Start\n",
|
||||
"\n",
|
||||
"In this comprehensive quick start, we will build a support chatbot in LangGraph that can:\n",
|
||||
"In this tutorial, we will build a support chatbot in LangGraph that can:\n",
|
||||
"\n",
|
||||
"- Answer common questions by searching the web\n",
|
||||
"- Maintain conversation state across calls\n",
|
||||
"- Route complex queries to a human for review\n",
|
||||
"- Use custom state to control its behavior\n",
|
||||
"- Rewind and explore alternative conversation paths\n",
|
||||
"✅ **Answer common questions** by searching the web \n",
|
||||
"✅ **Maintain conversation state** across calls \n",
|
||||
"✅ **Route complex queries** to a human for review \n",
|
||||
"✅ **Use custom state** to control its behavior \n",
|
||||
"✅ **Rewind and explore** alternative conversation paths \n",
|
||||
"\n",
|
||||
"We'll start with a basic chatbot and progressively add more sophisticated capabilities, introducing key LangGraph concepts along the way.\n",
|
||||
"We'll start with a **basic chatbot** and progressively add more sophisticated capabilities, introducing key LangGraph concepts along the way. Let’s dive in! 🌟\n",
|
||||
"\n",
|
||||
"## Setup\n",
|
||||
"\n",
|
||||
@@ -38,7 +38,7 @@
|
||||
"id": "a6d1e870-1bc0-4d44-86c0-96681ccf6113",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, set your API keys:"
|
||||
"In this tutorial, we'll be "
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -120,27 +120,24 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"attachments": {},
|
||||
"cell_type": "markdown",
|
||||
"id": "31c755cd-8994-4867-bdff-96a55d7beae7",
|
||||
"id": "c08c41da-0855-49d3-9a3d-b7eb94413367",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<div class=\"admonition tip\">\n",
|
||||
" <p class=\"admonition-title\">Note</p>\n",
|
||||
" <p>\n",
|
||||
" The first thing you do when you define a graph is define the <code>State</code> of the graph. The <code>State</code> consists of the schema of the graph as well as <a href=\"https://langchain-ai.github.io/langgraph/concepts/low_level/#reducers\">reducer functions</a> which specify how to apply updates to the state. In our example <code>State</code> is a <code>TypedDict</code> with a single key: <code>messages</code>. The <code>messages</code> key is annotated with the <a href=\"https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages\"><code>add_messages</code></a> reducer function, which tells LangGraph to append new messages to the existing list, rather than overwriting it. State keys without an annotation will be overwritten by each update, storing the most recent value. Check out <a href=\"https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages\">this conceptual guide</a> to learn more about state, reducers and other low-level concepts.\n",
|
||||
" </p>\n",
|
||||
"</div>"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "4137feed-746e-4c72-a34a-f7a699ad5dcf",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"So now our graph knows two things:\n",
|
||||
"Our graph can now handle two key tasks:\n",
|
||||
"\n",
|
||||
"1. Each `node` can receive the current `State` as input and output an update to the state.\n",
|
||||
"2. Updates to `messages` will be appended to the existing list rather than overwriting it, thanks to the prebuilt [`add_messages`](https://langchain-ai.github.io/langgraph/reference/graphs/?h=add+messages#add_messages) function used with the `Annotated` syntax.\n",
|
||||
"\n",
|
||||
"------\n",
|
||||
"\n",
|
||||
"!!! tip \"Concept\"\n",
|
||||
"\n",
|
||||
" When defining a graph, the first step is to define its `State`. The `State` includes the graph's schema and [reducer functions](https://langchain-ai.github.io/langgraph/concepts/low_level/#reducers) that handle state updates. In our example, `State` is a `TypedDict` with one key: `messages`. The [`add_messages`](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages) reducer function is used to append new messages to the list instead of overwriting it. Keys without a reducer annotation will overwrite previous values. Learn more about state, reducers, and related concepts in [this guide](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.message.add_messages).\n",
|
||||
"\n",
|
||||
"---------\n",
|
||||
"\n",
|
||||
"1. Every `node` we define will receive the current `State` as input and return a value that updates that state.\n",
|
||||
"2. `messages` will be _appended_ to the current list, rather than directly overwritten. This is communicated via the prebuilt [`add_messages`](https://langchain-ai.github.io/langgraph/reference/graphs/?h=add+messages#add_messages) function in the `Annotated` syntax.\n",
|
||||
"\n",
|
||||
"Next, add a \"`chatbot`\" node. Nodes represent units of work. They are typically regular python functions."
|
||||
]
|
||||
@@ -365,7 +362,7 @@
|
||||
"id": "f22c5d4a-3134-413c-81fe-dd9752fbeb66",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Part 2: Enhancing the Chatbot with Tools\n",
|
||||
"## Part 2: 🛠️ Enhancing the Chatbot with Tools\n",
|
||||
"\n",
|
||||
"To handle queries our chatbot can't answer \"from memory\", we'll integrate a web search tool. Our bot can use this tool to find relevant information and provide better responses.\n",
|
||||
"\n",
|
||||
@@ -2046,7 +2043,7 @@
|
||||
"\n",
|
||||
"So far, we've relied on a simple state (it's just a list of messages!). You can go far with this simple state, but if you want to define complex behavior without relying on the message list, you can add additional fields to the state. In this section, we will extend our chat bot with a new node to illustrate this.\n",
|
||||
"\n",
|
||||
"In the examples above, we involved a human deterministically: the graph __always__ interrupted whenever an tool was invoked. Suppose we wanted our chat bot to have the choice of relying on a human.\n",
|
||||
"In the examples above, we involved a human deterministically: the graph __always__ interrupted whenever a tool was invoked. Suppose we wanted our chat bot to have the choice of relying on a human.\n",
|
||||
"\n",
|
||||
"One way to do this is to create a passthrough \"human\" node, before which the graph will always stop. We will only execute this node if the LLM invokes a \"human\" tool. For our convenience, we will include an \"ask_human\" flag in our graph state that we will flip if the LLM calls this tool.\n",
|
||||
"\n",
|
||||
@@ -3136,11 +3133,29 @@
|
||||
"id": "e584d57f-5aad-4507-815f-0b2e4b64b791",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Conclusion\n",
|
||||
"## Next Steps\n",
|
||||
"\n",
|
||||
"Congrats! You've completed the intro tutorial and built a chat bot in LangGraph that supports tool calling, persistent memory, human-in-the-loop interactivity, and even time-travel!\n",
|
||||
"Take your journey further by exploring deployment and advanced features:\n",
|
||||
"\n",
|
||||
"The [LangGraph documentation](https://langchain-ai.github.io/langgraph/) is a great resource for diving deeper into the library's capabilities."
|
||||
"### Server Quickstart\n",
|
||||
"\n",
|
||||
"- **[LangGraph Server Quickstart](../langgraph-platform/local-server)**: Launch a LangGraph server locally and interact with it using the REST API and LangGraph Studio Web UI.\n",
|
||||
"\n",
|
||||
"### LangGraph Cloud\n",
|
||||
"\n",
|
||||
"- **[LangGraph Cloud QuickStart](../../cloud/quick_start)**: Deploy your LangGraph app using LangGraph Cloud.\n",
|
||||
"\n",
|
||||
"### LangGraph Framework\n",
|
||||
"\n",
|
||||
"- **[LangGraph Concepts](../../concepts)**: Learn the foundational concepts of LangGraph. \n",
|
||||
"- **[LangGraph How-to Guides](../../how-tos)**: Guides for common tasks with LangGraph.\n",
|
||||
"\n",
|
||||
"### LangGraph Platform\n",
|
||||
"\n",
|
||||
"Expand your knowledge with these resources:\n",
|
||||
"\n",
|
||||
"- **[LangGraph Platform Concepts](../../concepts#langgraph-platform)**: Understand the foundational concepts of the LangGraph Platform. \n",
|
||||
"- **[LangGraph Platform How-to Guides](../../how-tos#langgraph-platform)**: Guides for common tasks with LangGraph Platform. "
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -3160,7 +3175,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.9"
|
||||
"version": "3.11.4"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Quick Start: Launch Local LangGraph Server
|
||||
|
||||
This is a quick start guide to help you get a LangGraph app up and running locally.
|
||||
|
||||
!!! info "Requirements"
|
||||
|
||||
- Python >= 3.11
|
||||
- [LangGraph CLI](https://langchain-ai.github.io/langgraph/cloud/reference/cli/): Requires langchain-cli[inmem] >= 0.1.58
|
||||
|
||||
## Install the LangGraph CLI
|
||||
|
||||
```bash
|
||||
pip install "langgraph-cli[inmem]==0.1.58" python-dotenv
|
||||
```
|
||||
|
||||
## 🌱 Create a LangGraph App
|
||||
|
||||
Create a new app from the `react-agent` template. This template is a simple agent that can be flexibly extended to many tools.
|
||||
|
||||
=== "Python Server"
|
||||
|
||||
```shell
|
||||
langgraph new path/to/your/app --template react-agent-python
|
||||
```
|
||||
|
||||
=== "Node Server"
|
||||
|
||||
```shell
|
||||
langgraph new path/to/your/app --template react-agent-js
|
||||
```
|
||||
|
||||
!!! tip "Additional Templates"
|
||||
|
||||
If you use `langgraph new` without specifying a template, you will be presented with an interactive menu that will allow you to choose from a list of available templates.
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
In the root of your new LangGraph app, install the dependencies:
|
||||
|
||||
```shell
|
||||
pip install .
|
||||
```
|
||||
|
||||
## Create a `.env` file
|
||||
|
||||
You will find a `.env.example` in the root of your new LangGraph app. Create
|
||||
a `.env` file in the root of your new LangGraph app and copy the contents of the `.env.example` file into it, filling in the necessary API keys:
|
||||
|
||||
```bash
|
||||
LANGSMITH_API_KEY=lsv2...
|
||||
TAVILY_API_KEY=tvly-...
|
||||
ANTHROPIC_API_KEY=sk-
|
||||
OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
<details><summary>Get API Keys</summary>
|
||||
<ul>
|
||||
<li> <b>LANGSMITH_API_KEY</b>: Go to the <a href="https://smith.langchain.com/settings">LangSmith Settings page</a>. Then clck <b>Create API Key</b>.
|
||||
</li>
|
||||
<li>
|
||||
<b>ANTHROPIC_API_KEY</b>: Get an API key from <a href="https://console.anthropic.com/">Anthropic</a>.
|
||||
</li>
|
||||
<li>
|
||||
<b>OPENAI_API_KEY</b>: Get an API key from <a href="https://openai.com/">OpenAI</a>.
|
||||
</li>
|
||||
<li>
|
||||
<b>TAVILY_API_KEY</b>: Get an API key on the <a href="https://app.tavily.com/">Tavily website</a>.
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
## 🚀 Launch LangGraph Server
|
||||
|
||||
```shell
|
||||
langgraph dev
|
||||
```
|
||||
|
||||
This will start up the LangGraph API server locally. If this runs successfully, you should see something like:
|
||||
|
||||
> Ready!
|
||||
>
|
||||
> - API: [http://localhost:8123](http://localhost:8123/)
|
||||
>
|
||||
> - Docs: http://localhost:8123/docs
|
||||
>
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123
|
||||
|
||||
|
||||
!!! note "In-Memory Mode"
|
||||
|
||||
The `langgraph dev` command starts LangGraph Server in an in-memory mode. This mode is suitable for development and testing purposes. For production use, you should deploy LangGraph Server with access to a persistent storage backend.
|
||||
|
||||
If you want to test your application with a persistent storage backend, you can use the `langgraph up` command instead of `langgraph dev`. You will
|
||||
need to have `docker` installed on your machine to use this command.
|
||||
|
||||
## LangGraph Studio Web UI
|
||||
|
||||
Test your graph in the LangGraph Studio Web UI by visiting the URL provided in the output of the `langgraph up` command.
|
||||
|
||||
> - LangGraph Studio Web UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:8123
|
||||
|
||||
!!! warning "Safari Compatibility"
|
||||
|
||||
Currently, LangGraph Studio Web does not support Safari when running a server locally.
|
||||
|
||||
## Test the API
|
||||
|
||||
=== "Python SDK (Async)"
|
||||
|
||||
**Install the LangGraph Python SDK**
|
||||
|
||||
```shell
|
||||
pip install langgraph-sdk
|
||||
```
|
||||
|
||||
**Send a message to the assistant (threadless run)**
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client(url="http://localhost:8123")
|
||||
|
||||
async for chunk in client.runs.stream(
|
||||
None, # Threadless run
|
||||
"agent", # Name of assistant. Defined in langgraph.json.
|
||||
input={
|
||||
"messages": [{
|
||||
"role": "human",
|
||||
"content": "What is LangGraph?",
|
||||
}],
|
||||
},
|
||||
stream_mode="updates",
|
||||
):
|
||||
print(f"Receiving new event of type: {chunk.event}...")
|
||||
print(chunk.data)
|
||||
print("\n\n")
|
||||
```
|
||||
|
||||
=== "Python SDK (Sync)"
|
||||
|
||||
**Install the LangGraph Python SDK**
|
||||
|
||||
```shell
|
||||
pip install langgraph-sdk
|
||||
```
|
||||
|
||||
**Send a message to the assistant (threadless run)**
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_sync_client
|
||||
|
||||
client = get_sync_client(url="http://localhost:8123")
|
||||
|
||||
for chunk in client.runs.stream(
|
||||
None, # Threadless run
|
||||
"agent", # Name of assistant. Defined in langgraph.json.
|
||||
input={
|
||||
"messages": [{
|
||||
"role": "human",
|
||||
"content": "What is LangGraph?",
|
||||
}],
|
||||
},
|
||||
stream_mode="updates",
|
||||
):
|
||||
print(f"Receiving new event of type: {chunk.event}...")
|
||||
print(chunk.data)
|
||||
print("\n\n")
|
||||
```
|
||||
|
||||
=== "Javascript SDK"
|
||||
|
||||
**Install the LangGraph JS SDK**
|
||||
|
||||
```shell
|
||||
npm install @langchain/langgraph-sdk
|
||||
```
|
||||
|
||||
**Send a message to the assistant (threadless run)**
|
||||
|
||||
```js
|
||||
const { Client } = await import("@langchain/langgraph-sdk");
|
||||
|
||||
// only set the apiUrl if you changed the default port when calling langgraph up
|
||||
const client = new Client({ apiUrl: "http://localhost:8123"});
|
||||
|
||||
const streamResponse = client.runs.stream(
|
||||
null, // Threadless run
|
||||
"agent", // Assistant ID
|
||||
{
|
||||
input: {
|
||||
"messages": [
|
||||
{ "role": "user", "content": "What is LangGraph?"}
|
||||
]
|
||||
},
|
||||
streamMode: "messages",
|
||||
}
|
||||
);
|
||||
|
||||
for await (const chunk of streamResponse) {
|
||||
console.log(`Receiving new event of type: ${chunk.event}...`);
|
||||
console.log(JSON.stringify(chunk.data));
|
||||
console.log("\n\n");
|
||||
}
|
||||
```
|
||||
|
||||
=== "Rest API"
|
||||
|
||||
```bash
|
||||
curl -s --request POST \
|
||||
--url "http://localhost:8123/runs/stream" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{
|
||||
\"assistant_id\": \"agent\",
|
||||
\"input\": {
|
||||
\"messages\": [
|
||||
{
|
||||
\"role\": \"human\",
|
||||
\"content\": \"What is LangGraph?\"
|
||||
}
|
||||
]
|
||||
},
|
||||
\"stream_mode\": \"updates\"
|
||||
}"
|
||||
```
|
||||
|
||||
!!! tip "Auth"
|
||||
|
||||
If you're connecting to a remote server, you will need to provide a LangSmith
|
||||
API Key for authorization. Please see the API Reference for the clients
|
||||
for more information.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Now that you have a LangGraph app running locally, take your journey further by exploring deployment and advanced features:
|
||||
|
||||
### 🌐 Deploy to LangGraph Cloud
|
||||
|
||||
- **[LangGraph Cloud QuickStart](../../cloud/quick_start.md)**: Deploy your LangGraph app using LangGraph Cloud.
|
||||
|
||||
### 📚 Learn More about LangGraph Platform
|
||||
|
||||
Expand your knowledge with these resources:
|
||||
|
||||
- **[LangGraph Platform Concepts](../../concepts/index.md#langgraph-platform)**: Understand the foundational concepts of the LangGraph Platform.
|
||||
- **[LangGraph Platform How-to Guides](../../how-tos/index.md#langgraph-platform)**: Discover step-by-step guides to build and deploy applications.
|
||||
|
||||
### 🛠️ Developer References
|
||||
|
||||
Access detailed documentation for development and API usage:
|
||||
|
||||
- **[LangGraph Server API Reference](../../cloud/reference/api/api_ref.html)**: Explore the LangGraph Server API documentation.
|
||||
- **[Python SDK Reference](../../cloud/reference/sdk/python_sdk_ref.md)**: Explore the Python SDK API Reference.
|
||||
- **[JS/TS SDK Reference](../../cloud/reference/sdk/js_ts_sdk_ref.md)**: Explore the Python SDK API Reference.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -934,7 +934,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.9"
|
||||
"version": "3.11.4"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"_set_env(\"LANGCHAIN_API_KEY\")\n",
|
||||
"_set_env(\"LANGSMITH_API_KEY\")\n",
|
||||
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
|
||||
"os.environ[\"LANGCHAIN_PROJECT\"] = \"local-llama32-rag\""
|
||||
]
|
||||
|
||||
+240
-6
@@ -25,6 +25,7 @@ theme:
|
||||
- navigation.sections
|
||||
- navigation.instant.prefetch
|
||||
- navigation.instant.progress
|
||||
- navigation.path
|
||||
- navigation.prune
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
@@ -87,12 +88,243 @@ plugins:
|
||||
filters:
|
||||
- "!^_"
|
||||
nav:
|
||||
# Setting the names of the nav items explicitly due to mkdocs
|
||||
# how-reload being a bit buggy with the names of the tabs.
|
||||
- Home: "index.md"
|
||||
- Tutorials: "tutorials/index.md"
|
||||
- Concepts: "concepts/index.md"
|
||||
- "How-to Guides": "how-tos/index.md"
|
||||
- Home: index.md
|
||||
- Tutorials:
|
||||
- tutorials/index.md
|
||||
- Quick Start:
|
||||
- Quick Start: tutorials#quick-start
|
||||
- tutorials/introduction.ipynb
|
||||
- tutorials/langgraph-platform/local-server.md
|
||||
- cloud/quick_start.md
|
||||
- Chatbots:
|
||||
- Chatbots: tutorials#chatbots
|
||||
- tutorials/customer-support/customer-support.ipynb
|
||||
- tutorials/chatbots/information-gather-prompting.ipynb
|
||||
- tutorials/code_assistant/langgraph_code_assistant.ipynb
|
||||
- RAG:
|
||||
- RAG: tutorials#rag
|
||||
- tutorials/rag/langgraph_adaptive_rag.ipynb
|
||||
- tutorials/rag/langgraph_adaptive_rag_local.ipynb
|
||||
- tutorials/rag/langgraph_agentic_rag.ipynb
|
||||
- tutorials/rag/langgraph_crag.ipynb
|
||||
- tutorials/rag/langgraph_crag_local.ipynb
|
||||
- tutorials/rag/langgraph_self_rag.ipynb
|
||||
- tutorials/rag/langgraph_self_rag_local.ipynb
|
||||
- tutorials/sql-agent.ipynb
|
||||
- Agent Architectures:
|
||||
- Agent Architectures: tutorials#agent-architectures
|
||||
- Multi-Agent Systems:
|
||||
- Multi-Agent Systems: tutorials#multi-agent-systems
|
||||
- tutorials/multi_agent/multi-agent-collaboration.ipynb
|
||||
- tutorials/multi_agent/agent_supervisor.ipynb
|
||||
- tutorials/multi_agent/hierarchical_agent_teams.ipynb
|
||||
- Planning Agents:
|
||||
- Planning Agents: tutorials#planning-agents
|
||||
- tutorials/plan-and-execute/plan-and-execute.ipynb
|
||||
- tutorials/rewoo/rewoo.ipynb
|
||||
- tutorials/llm-compiler/LLMCompiler.ipynb
|
||||
- Reflection & Critique:
|
||||
- Reflection & Critique: tutorials#reflection-critique
|
||||
- tutorials/reflection/reflection.ipynb
|
||||
- tutorials/reflexion/reflexion.ipynb
|
||||
- tutorials/tot/tot.ipynb
|
||||
- tutorials/lats/lats.ipynb
|
||||
- tutorials/self-discover/self-discover.ipynb
|
||||
- Evaluation & Analysis:
|
||||
- Evaluation & Analysis: tutorials#evaluation
|
||||
- tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb
|
||||
- tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb
|
||||
- Experimental:
|
||||
- Experimental: tutorials#experimental
|
||||
- tutorials/storm/storm.ipynb
|
||||
- tutorials/tnt-llm/tnt-llm.ipynb
|
||||
- tutorials/web-navigation/web_voyager.ipynb
|
||||
- tutorials/usaco/usaco.ipynb
|
||||
- tutorials/extraction/retries.ipynb
|
||||
|
||||
- How-to Guides:
|
||||
- how-tos/index.md
|
||||
- LangGraph:
|
||||
- LangGraph: how-tos#langgraph
|
||||
- Controllability:
|
||||
- Controllability: how-tos#controllability
|
||||
- how-tos/branching.ipynb
|
||||
- how-tos/map-reduce.ipynb
|
||||
- how-tos/recursion-limit.ipynb
|
||||
- Persistence:
|
||||
- Persistence: how-tos#persistence
|
||||
- how-tos/persistence.ipynb
|
||||
- how-tos/subgraph-persistence.ipynb
|
||||
- how-tos/cross-thread-persistence.ipynb
|
||||
- how-tos/persistence_postgres.ipynb
|
||||
- how-tos/persistence_mongodb.ipynb
|
||||
- how-tos/persistence_redis.ipynb
|
||||
- Memory:
|
||||
- Memory: how-tos#memory
|
||||
- how-tos/memory/manage-conversation-history.ipynb
|
||||
- how-tos/memory/delete-messages.ipynb
|
||||
- how-tos/memory/add-summary-conversation-history.ipynb
|
||||
- Human-in-the-loop:
|
||||
- Human-in-the-loop: how-tos#human-in-the-loop
|
||||
- how-tos/human_in_the_loop/breakpoints.ipynb
|
||||
- how-tos/human_in_the_loop/dynamic_breakpoints.ipynb
|
||||
- how-tos/human_in_the_loop/edit-graph-state.ipynb
|
||||
- how-tos/human_in_the_loop/wait-user-input.ipynb
|
||||
- how-tos/human_in_the_loop/time-travel.ipynb
|
||||
- how-tos/human_in_the_loop/review-tool-calls.ipynb
|
||||
- Streaming:
|
||||
- Streaming: how-tos#streaming
|
||||
- how-tos/stream-values.ipynb
|
||||
- how-tos/stream-updates.ipynb
|
||||
- how-tos/streaming-tokens.ipynb
|
||||
- how-tos/streaming-tokens-without-langchain.ipynb
|
||||
- how-tos/streaming-content.ipynb
|
||||
- how-tos/stream-multiple.ipynb
|
||||
- how-tos/streaming-events-from-within-tools.ipynb
|
||||
- how-tos/streaming-events-from-within-tools-without-langchain.ipynb
|
||||
- how-tos/streaming-from-final-node.ipynb
|
||||
- how-tos/streaming-subgraphs.ipynb
|
||||
- how-tos/disable-streaming.ipynb
|
||||
- Tool calling:
|
||||
- Tool calling: how-tos#tool-calling
|
||||
- how-tos/tool-calling.ipynb
|
||||
- how-tos/tool-calling-errors.ipynb
|
||||
- how-tos/pass-run-time-values-to-tools.ipynb
|
||||
- how-tos/pass-config-to-tools.ipynb
|
||||
- how-tos/many-tools.ipynb
|
||||
- Subgraphs:
|
||||
- Subgraphs: how-tos#subgraphs
|
||||
- how-tos/subgraph.ipynb
|
||||
- how-tos/subgraphs-manage-state.ipynb
|
||||
- how-tos/subgraph-transform-state.ipynb
|
||||
- State Management:
|
||||
- State Management: how-tos#state-management
|
||||
- how-tos/state-model.ipynb
|
||||
- how-tos/input_output_schema.ipynb
|
||||
- how-tos/pass_private_state.ipynb
|
||||
- Other:
|
||||
- Other: how-tos#other
|
||||
- how-tos/async.ipynb
|
||||
- how-tos/visualization.ipynb
|
||||
- how-tos/configuration.ipynb
|
||||
- how-tos/node-retries.ipynb
|
||||
- how-tos/react-agent-structured-output.ipynb
|
||||
- how-tos/run-id-langsmith.ipynb
|
||||
- how-tos/return-when-recursion-limit-hits.ipynb
|
||||
- Prebuilt ReAct Agent:
|
||||
- Prebuilt ReAct Agent: how-tos#prebuilt-react-agent
|
||||
- how-tos/create-react-agent.ipynb
|
||||
- how-tos/create-react-agent-memory.ipynb
|
||||
- how-tos/create-react-agent-system-prompt.ipynb
|
||||
- how-tos/create-react-agent-hitl.ipynb
|
||||
- how-tos/react-agent-from-scratch.ipynb
|
||||
- LangGraph Platform:
|
||||
- LangGraph Platform: how-tos#langgraph-platform
|
||||
- Application Structure:
|
||||
- Application Structure: how-tos#application-structure
|
||||
- cloud/deployment/setup.md
|
||||
- cloud/deployment/setup_pyproject.md
|
||||
- cloud/deployment/setup_javascript.md
|
||||
- cloud/deployment/custom_docker.md
|
||||
- cloud/deployment/test_locally.md
|
||||
- cloud/deployment/graph_rebuild.md
|
||||
- Deployment:
|
||||
- Deployment: how-tos#deployment
|
||||
- cloud/deployment/cloud.md
|
||||
- how-tos/deploy-self-hosted.md
|
||||
- how-tos/use-remote-graph.md
|
||||
- Assistants:
|
||||
- Assistants: how-tos#assistants
|
||||
- cloud/how-tos/configuration_cloud.md
|
||||
- cloud/how-tos/assistant_versioning.md
|
||||
- Threads:
|
||||
- Threads: how-tos#threads
|
||||
- cloud/how-tos/copy_threads.md
|
||||
- cloud/how-tos/check_thread_status.md
|
||||
- Runs:
|
||||
- Runs: how-tos#runs
|
||||
- cloud/how-tos/background_run.md
|
||||
- cloud/how-tos/same-thread.md
|
||||
- cloud/how-tos/cron_jobs.md
|
||||
- cloud/how-tos/stateless_runs.md
|
||||
- Streaming:
|
||||
- Streaming: how-tos#streaming_1
|
||||
- cloud/how-tos/stream_values.md
|
||||
- cloud/how-tos/stream_updates.md
|
||||
- cloud/how-tos/stream_messages.md
|
||||
- cloud/how-tos/stream_events.md
|
||||
- cloud/how-tos/stream_debug.md
|
||||
- cloud/how-tos/stream_multiple.md
|
||||
- Human-in-the-loop:
|
||||
- Human-in-the-loop: how-tos#human-in-the-loop_1
|
||||
- cloud/how-tos/human_in_the_loop_breakpoint.md
|
||||
- cloud/how-tos/human_in_the_loop_user_input.md
|
||||
- cloud/how-tos/human_in_the_loop_edit_state.md
|
||||
- cloud/how-tos/human_in_the_loop_time_travel.md
|
||||
- cloud/how-tos/human_in_the_loop_review_tool_calls.md
|
||||
- Double-texting:
|
||||
- Double-texting: how-tos#double-texting
|
||||
- cloud/how-tos/interrupt_concurrent.md
|
||||
- cloud/how-tos/rollback_concurrent.md
|
||||
- cloud/how-tos/reject_concurrent.md
|
||||
- cloud/how-tos/enqueue_concurrent.md
|
||||
- Webhooks:
|
||||
- cloud/how-tos/webhooks.md
|
||||
- Cron Jobs:
|
||||
- cloud/how-tos/cron_jobs.md
|
||||
- LangGraph Studio:
|
||||
- LangGraph Studio: how-tos#langgraph-studio
|
||||
- cloud/how-tos/test_deployment.md
|
||||
- cloud/how-tos/test_local_deployment.md
|
||||
- cloud/how-tos/invoke_studio.md
|
||||
- cloud/how-tos/threads_studio.md
|
||||
- Troubleshooting:
|
||||
- Troubleshooting: how-tos#troubleshooting
|
||||
- troubleshooting/errors/index.md
|
||||
- troubleshooting/errors/GRAPH_RECURSION_LIMIT.md
|
||||
- troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md
|
||||
- troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md
|
||||
- troubleshooting/errors/MULTIPLE_SUBGRAPHS.md
|
||||
|
||||
- Conceptual Guides:
|
||||
- concepts/index.md
|
||||
- LangGraph:
|
||||
- LangGraph: concepts#langgraph
|
||||
- concepts/high_level.md
|
||||
- concepts/low_level.md
|
||||
- concepts/agentic_concepts.md
|
||||
- concepts/multi_agent.md
|
||||
- concepts/human_in_the_loop.md
|
||||
- concepts/persistence.md
|
||||
- concepts/memory.md
|
||||
- concepts/streaming.md
|
||||
- concepts/faq.md
|
||||
- LangGraph Platform:
|
||||
- LangGraph Platform: concepts#langgraph-platform
|
||||
- High Level:
|
||||
- High Level: concepts#high-level
|
||||
- concepts/langgraph_platform.md
|
||||
- concepts/deployment_options.md
|
||||
- concepts/plans.md
|
||||
- concepts/template_applications.md
|
||||
- Components:
|
||||
- Components: concepts#components
|
||||
- concepts/langgraph_server.md
|
||||
- concepts/langgraph_studio.md
|
||||
- concepts/langgraph_cli.md
|
||||
- concepts/sdk.md
|
||||
- how-tos/use-remote-graph.md
|
||||
- LangGraph Server:
|
||||
- LangGraph Server: concepts#langgraph-server
|
||||
- concepts/application_structure.md
|
||||
- concepts/assistants.md
|
||||
- concepts/double_texting.md
|
||||
- Deployment Options:
|
||||
- Deployment Options: concepts#deployment-options
|
||||
- concepts/self_hosted.md
|
||||
- concepts/langgraph_cloud.md
|
||||
- concepts/bring_your_own_cloud.md
|
||||
|
||||
- Reference:
|
||||
- "reference/index.md"
|
||||
- Library:
|
||||
@@ -206,3 +438,5 @@ validation:
|
||||
# because it creates anchors in the generated report
|
||||
# and those anchors are not available in the actual doc
|
||||
anchors: info
|
||||
# this is needed to handle headers with anchors for nav
|
||||
not_found: info
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
color: #1E88E5;
|
||||
}
|
||||
|
||||
.md-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show sidebar on mobile */
|
||||
@media screen and (max-width: 1220px) {
|
||||
.md-sidebar--primary {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.md-typeset a:hover {
|
||||
color: #1565C0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator, Optional, Sequence, Union
|
||||
from typing import Any, Iterator, Optional, Sequence
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from psycopg import Connection, Cursor, Pipeline
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
@@ -17,21 +17,11 @@ from langgraph.checkpoint.base import (
|
||||
CheckpointTuple,
|
||||
get_checkpoint_id,
|
||||
)
|
||||
from langgraph.checkpoint.postgres import _internal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
|
||||
Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
|
||||
if isinstance(conn, Connection):
|
||||
yield conn
|
||||
elif isinstance(conn, ConnectionPool):
|
||||
with conn.connection() as conn:
|
||||
yield conn
|
||||
else:
|
||||
raise TypeError(f"Invalid connection type: {type(conn)}")
|
||||
Conn = _internal.Conn # For backward compatibility
|
||||
|
||||
|
||||
class PostgresSaver(BasePostgresSaver):
|
||||
@@ -39,7 +29,7 @@ class PostgresSaver(BasePostgresSaver):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Conn,
|
||||
conn: _internal.Conn,
|
||||
pipe: Optional[Pipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
@@ -52,6 +42,7 @@ class PostgresSaver(BasePostgresSaver):
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.lock = threading.Lock()
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
@@ -72,9 +63,9 @@ class PostgresSaver(BasePostgresSaver):
|
||||
) as conn:
|
||||
if pipeline:
|
||||
with conn.pipeline() as pipe:
|
||||
yield PostgresSaver(conn, pipe)
|
||||
yield cls(conn, pipe)
|
||||
else:
|
||||
yield PostgresSaver(conn)
|
||||
yield cls(conn)
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
@@ -365,7 +356,14 @@ class PostgresSaver(BasePostgresSaver):
|
||||
|
||||
@contextmanager
|
||||
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
||||
with _get_connection(self.conn) as conn:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
||||
Will be applied regardless of whether the PostgresSaver instance was initialized with a pipeline.
|
||||
If pipeline mode is not supported, will fall back to using transaction context manager.
|
||||
"""
|
||||
with _internal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
# in multiple threads/coroutines, but only one cursor can be
|
||||
@@ -379,13 +377,20 @@ class PostgresSaver(BasePostgresSaver):
|
||||
elif pipeline:
|
||||
# a connection not in pipeline mode can only be used by one
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
with self.lock, conn.pipeline(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
if self.supports_pipeline:
|
||||
with self.lock, conn.pipeline(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
# Use connection's transaction context manager when pipeline mode not supported
|
||||
with self.lock, conn.transaction(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
with self.lock, conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
|
||||
__all__ = ["PostgresSaver", "Conn"]
|
||||
__all__ = ["PostgresSaver", "BasePostgresSaver", "Conn"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared async utility functions for the Postgres checkpoint & storage classes."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Union
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import DictRow
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection(
|
||||
conn: Conn,
|
||||
) -> AsyncIterator[AsyncConnection[DictRow]]:
|
||||
if isinstance(conn, AsyncConnection):
|
||||
yield conn
|
||||
elif isinstance(conn, AsyncConnectionPool):
|
||||
async with conn.connection() as conn:
|
||||
yield conn
|
||||
else:
|
||||
raise TypeError(f"Invalid connection type: {type(conn)}")
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared utility functions for the Postgres checkpoint & storage classes."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator, Union
|
||||
|
||||
from psycopg import Connection
|
||||
from psycopg.rows import DictRow
|
||||
from psycopg_pool import ConnectionPool
|
||||
|
||||
Conn = Union[Connection[DictRow], ConnectionPool[Connection[DictRow]]]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection(conn: Conn) -> Iterator[Connection[DictRow]]:
|
||||
if isinstance(conn, Connection):
|
||||
yield conn
|
||||
elif isinstance(conn, ConnectionPool):
|
||||
with conn.connection() as conn:
|
||||
yield conn
|
||||
else:
|
||||
raise TypeError(f"Invalid connection type: {type(conn)}")
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Iterator, Optional, Sequence, Union
|
||||
from typing import Any, AsyncIterator, Iterator, Optional, Sequence
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
@@ -17,23 +17,11 @@ from langgraph.checkpoint.base import (
|
||||
CheckpointTuple,
|
||||
get_checkpoint_id,
|
||||
)
|
||||
from langgraph.checkpoint.postgres import _ainternal
|
||||
from langgraph.checkpoint.postgres.base import BasePostgresSaver
|
||||
from langgraph.checkpoint.serde.base import SerializerProtocol
|
||||
|
||||
Conn = Union[AsyncConnection[DictRow], AsyncConnectionPool[AsyncConnection[DictRow]]]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_connection(
|
||||
conn: Conn,
|
||||
) -> AsyncIterator[AsyncConnection[DictRow]]:
|
||||
if isinstance(conn, AsyncConnection):
|
||||
yield conn
|
||||
elif isinstance(conn, AsyncConnectionPool):
|
||||
async with conn.connection() as conn:
|
||||
yield conn
|
||||
else:
|
||||
raise TypeError(f"Invalid connection type: {type(conn)}")
|
||||
Conn = _ainternal.Conn # For backward compatibility
|
||||
|
||||
|
||||
class AsyncPostgresSaver(BasePostgresSaver):
|
||||
@@ -41,7 +29,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Conn,
|
||||
conn: _ainternal.Conn,
|
||||
pipe: Optional[AsyncPipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
@@ -55,6 +43,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
self.pipe = pipe
|
||||
self.lock = asyncio.Lock()
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
@@ -79,9 +68,9 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
) as conn:
|
||||
if pipeline:
|
||||
async with conn.pipeline() as pipe:
|
||||
yield AsyncPostgresSaver(conn=conn, pipe=pipe, serde=serde)
|
||||
yield cls(conn=conn, pipe=pipe, serde=serde)
|
||||
else:
|
||||
yield AsyncPostgresSaver(conn=conn, serde=serde)
|
||||
yield cls(conn=conn, serde=serde)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
@@ -156,15 +145,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
value["pending_sends"],
|
||||
),
|
||||
self._load_metadata(value["metadata"]),
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": value["thread_id"],
|
||||
"checkpoint_ns": value["checkpoint_ns"],
|
||||
"checkpoint_id": value["parent_checkpoint_id"],
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": value["thread_id"],
|
||||
"checkpoint_ns": value["checkpoint_ns"],
|
||||
"checkpoint_id": value["parent_checkpoint_id"],
|
||||
}
|
||||
}
|
||||
}
|
||||
if value["parent_checkpoint_id"]
|
||||
else None,
|
||||
if value["parent_checkpoint_id"]
|
||||
else None
|
||||
),
|
||||
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
||||
)
|
||||
|
||||
@@ -215,15 +206,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
value["pending_sends"],
|
||||
),
|
||||
self._load_metadata(value["metadata"]),
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": value["parent_checkpoint_id"],
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"checkpoint_id": value["parent_checkpoint_id"],
|
||||
}
|
||||
}
|
||||
}
|
||||
if value["parent_checkpoint_id"]
|
||||
else None,
|
||||
if value["parent_checkpoint_id"]
|
||||
else None
|
||||
),
|
||||
await asyncio.to_thread(self._load_writes, value["pending_writes"]),
|
||||
)
|
||||
|
||||
@@ -323,7 +316,14 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
async def _cursor(
|
||||
self, *, pipeline: bool = False
|
||||
) -> AsyncIterator[AsyncCursor[DictRow]]:
|
||||
async with _get_connection(self.conn) as conn:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
||||
Will be applied regardless of whether the AsyncPostgresSaver instance was initialized with a pipeline.
|
||||
If pipeline mode is not supported, will fall back to using transaction context manager.
|
||||
"""
|
||||
async with _ainternal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
# in multiple threads/coroutines, but only one cursor can be
|
||||
@@ -337,10 +337,17 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
elif pipeline:
|
||||
# a connection not in pipeline mode can only be used by one
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
async with self.lock, conn.pipeline(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
if self.supports_pipeline:
|
||||
async with self.lock, conn.pipeline(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
# Use connection's transaction context manager when pipeline mode not supported
|
||||
async with self.lock, conn.transaction(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
async with self.lock, conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
@@ -452,3 +459,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aput_writes(config, writes, task_id), self.loop
|
||||
).result()
|
||||
|
||||
|
||||
__all__ = ["AsyncPostgresSaver", "Conn"]
|
||||
|
||||
@@ -84,7 +84,7 @@ select
|
||||
and cw.checkpoint_id = checkpoints.checkpoint_id
|
||||
) as pending_writes,
|
||||
(
|
||||
select array_agg(array[cw.type::bytea, cw.blob] order by cw.idx)
|
||||
select array_agg(array[cw.type::bytea, cw.blob] order by cw.task_id, cw.idx)
|
||||
from checkpoint_writes cw
|
||||
where cw.thread_id = checkpoints.thread_id
|
||||
and cw.checkpoint_ns = checkpoints.checkpoint_ns
|
||||
@@ -133,6 +133,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
|
||||
|
||||
jsonplus_serde = JsonPlusSerializer()
|
||||
supports_pipeline: bool
|
||||
|
||||
def _load_checkpoint(
|
||||
self,
|
||||
|
||||
@@ -13,14 +13,17 @@ from typing import (
|
||||
)
|
||||
|
||||
import orjson
|
||||
from psycopg import AsyncConnection, AsyncCursor
|
||||
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline, Capabilities
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
from langgraph.checkpoint.postgres import _ainternal
|
||||
from langgraph.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
|
||||
from langgraph.store.base.batch import AsyncBatchedBaseStore
|
||||
from langgraph.store.postgres.base import (
|
||||
BasePostgresStore,
|
||||
PoolConfig,
|
||||
Row,
|
||||
_decode_ns_bytes,
|
||||
_group_ops,
|
||||
@@ -30,81 +33,88 @@ from langgraph.store.postgres.base import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnection]):
|
||||
__slots__ = ("_deserializer",)
|
||||
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
|
||||
__slots__ = ("_deserializer", "pipe", "lock", "supports_pipeline")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: AsyncConnection[Any],
|
||||
conn: _ainternal.Conn,
|
||||
*,
|
||||
pipe: Optional[AsyncPipeline] = None,
|
||||
deserializer: Optional[
|
||||
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
||||
] = None,
|
||||
) -> None:
|
||||
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
|
||||
raise ValueError(
|
||||
"Pipeline should be used only with a single AsyncConnection, not AsyncConnectionPool."
|
||||
)
|
||||
super().__init__()
|
||||
self._deserializer = deserializer
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.lock = asyncio.Lock()
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
|
||||
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
grouped_ops, num_ops = _group_ops(ops)
|
||||
results: list[Result] = [None] * num_ops
|
||||
|
||||
async with self.conn.pipeline():
|
||||
tasks = []
|
||||
|
||||
if GetOp in grouped_ops:
|
||||
tasks.append(
|
||||
self._batch_get_ops(
|
||||
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
|
||||
)
|
||||
)
|
||||
|
||||
if PutOp in grouped_ops:
|
||||
tasks.append(
|
||||
self._batch_put_ops(
|
||||
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
|
||||
)
|
||||
)
|
||||
|
||||
if SearchOp in grouped_ops:
|
||||
tasks.append(
|
||||
self._batch_search_ops(
|
||||
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
||||
results,
|
||||
)
|
||||
)
|
||||
|
||||
if ListNamespacesOp in grouped_ops:
|
||||
tasks.append(
|
||||
self._batch_list_namespaces_ops(
|
||||
cast(
|
||||
Sequence[tuple[int, ListNamespacesOp]],
|
||||
grouped_ops[ListNamespacesOp],
|
||||
),
|
||||
results,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
async with _ainternal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
async with self.pipe:
|
||||
await self._execute_batch(grouped_ops, results, conn)
|
||||
else:
|
||||
await self._execute_batch(grouped_ops, results, conn)
|
||||
|
||||
return results
|
||||
|
||||
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
|
||||
async def _execute_batch(
|
||||
self,
|
||||
grouped_ops: dict,
|
||||
results: list[Result],
|
||||
conn: AsyncConnection[DictRow],
|
||||
) -> None:
|
||||
async with self._cursor(conn, pipeline=True) as cur:
|
||||
if GetOp in grouped_ops:
|
||||
await self._batch_get_ops(
|
||||
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]),
|
||||
results,
|
||||
cur,
|
||||
)
|
||||
|
||||
if SearchOp in grouped_ops:
|
||||
await self._batch_search_ops(
|
||||
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
||||
results,
|
||||
cur,
|
||||
)
|
||||
|
||||
if ListNamespacesOp in grouped_ops:
|
||||
await self._batch_list_namespaces_ops(
|
||||
cast(
|
||||
Sequence[tuple[int, ListNamespacesOp]],
|
||||
grouped_ops[ListNamespacesOp],
|
||||
),
|
||||
results,
|
||||
cur,
|
||||
)
|
||||
|
||||
if PutOp in grouped_ops:
|
||||
await self._batch_put_ops(
|
||||
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]),
|
||||
cur,
|
||||
)
|
||||
|
||||
async def _batch_get_ops(
|
||||
self,
|
||||
get_ops: Sequence[tuple[int, GetOp]],
|
||||
results: list[Result],
|
||||
cur: AsyncCursor[DictRow],
|
||||
) -> None:
|
||||
cursors = []
|
||||
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
await cur.execute(query, params)
|
||||
cursors.append((cur, namespace, items))
|
||||
|
||||
for cur, namespace, items in cursors:
|
||||
rows = cast(list[Row], await cur.fetchall())
|
||||
key_to_row = {row["key"]: row for row in rows}
|
||||
for idx, key in items:
|
||||
@@ -119,26 +129,21 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnectio
|
||||
async def _batch_put_ops(
|
||||
self,
|
||||
put_ops: Sequence[tuple[int, PutOp]],
|
||||
cur: AsyncCursor[DictRow],
|
||||
) -> None:
|
||||
queries = self._get_batch_PUT_queries(put_ops)
|
||||
for query, params in queries:
|
||||
cur = self.conn.cursor(binary=True)
|
||||
await cur.execute(query, params)
|
||||
|
||||
async def _batch_search_ops(
|
||||
self,
|
||||
search_ops: Sequence[tuple[int, SearchOp]],
|
||||
results: list[Result],
|
||||
cur: AsyncCursor[DictRow],
|
||||
) -> None:
|
||||
queries = self._get_batch_search_queries(search_ops)
|
||||
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
||||
|
||||
for (query, params), (idx, _) in zip(queries, search_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
await cur.execute(query, params)
|
||||
cursors.append((cur, idx))
|
||||
|
||||
for cur, idx in cursors:
|
||||
rows = cast(list[Row], await cur.fetchall())
|
||||
items = [
|
||||
_row_to_item(
|
||||
@@ -152,37 +157,103 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnectio
|
||||
self,
|
||||
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
||||
results: list[Result],
|
||||
cur: AsyncCursor[DictRow],
|
||||
) -> None:
|
||||
queries = self._get_batch_list_namespaces_queries(list_ops)
|
||||
cursors: list[tuple[AsyncCursor[Any], int]] = []
|
||||
for (query, params), (idx, _) in zip(queries, list_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
await cur.execute(query, params)
|
||||
cursors.append((cur, idx))
|
||||
|
||||
for cur, idx in cursors:
|
||||
rows = cast(list[dict], await cur.fetchall())
|
||||
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
||||
results[idx] = namespaces
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cursor(
|
||||
self, conn: AsyncConnection[DictRow], *, pipeline: bool = False
|
||||
) -> AsyncIterator[AsyncCursor[Any]]:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
conn: The database connection to use
|
||||
pipeline: whether to use pipeline for the DB operations inside the context manager.
|
||||
Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
|
||||
If pipeline mode is not supported, will fall back to using transaction context manager.
|
||||
"""
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
# in multiple threads/coroutines, but only one cursor can be
|
||||
# used at a time
|
||||
async with conn.cursor(binary=True) as cur:
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
if pipeline:
|
||||
await self.pipe.sync()
|
||||
elif pipeline:
|
||||
# a connection not in pipeline mode can only be used by one
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
if self.supports_pipeline:
|
||||
async with self.lock, conn.pipeline(), conn.cursor(binary=True) as cur:
|
||||
yield cur
|
||||
else:
|
||||
async with self.lock, conn.transaction(), conn.cursor(
|
||||
binary=True
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
async with conn.cursor(binary=True) as cur:
|
||||
yield cur
|
||||
|
||||
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return asyncio.run_coroutine_threadsafe(self.abatch(ops), self.loop).result()
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
*,
|
||||
pipeline: bool = False,
|
||||
pool_config: Optional[PoolConfig] = None,
|
||||
) -> AsyncIterator["AsyncPostgresStore"]:
|
||||
"""Create a new AsyncPostgresStore instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
pipeline (bool): Whether to use AsyncPipeline (only for single connections)
|
||||
pool_config (Optional[PoolConfig]): Configuration for the connection pool.
|
||||
If provided, will create a connection pool and use it instead of a single connection.
|
||||
This overrides the `pipeline` argument.
|
||||
|
||||
Returns:
|
||||
AsyncPostgresStore: A new AsyncPostgresStore instance.
|
||||
"""
|
||||
async with await AsyncConnection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
yield cls(conn=conn)
|
||||
if pool_config is not None:
|
||||
pc = pool_config.copy()
|
||||
async with cast(
|
||||
AsyncConnectionPool[AsyncConnection[DictRow]],
|
||||
AsyncConnectionPool(
|
||||
conn_string,
|
||||
min_size=pc.pop("min_size", 1),
|
||||
max_size=pc.pop("max_size", None),
|
||||
kwargs={
|
||||
"autocommit": True,
|
||||
"prepare_threshold": 0,
|
||||
"row_factory": dict_row,
|
||||
**(pc.pop("kwargs", None) or {}),
|
||||
},
|
||||
**cast(dict, pc),
|
||||
),
|
||||
) as pool:
|
||||
yield cls(conn=pool)
|
||||
else:
|
||||
async with await AsyncConnection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
if pipeline:
|
||||
async with conn.pipeline() as pipe:
|
||||
yield cls(conn=conn, pipe=pipe)
|
||||
else:
|
||||
yield cls(conn=conn)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the store database asynchronously.
|
||||
@@ -191,28 +262,33 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnectio
|
||||
already exist and runs database migrations. It MUST be called directly by the user
|
||||
the first time the store is used.
|
||||
"""
|
||||
async with self.conn.cursor() as cur:
|
||||
try:
|
||||
await cur.execute(
|
||||
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
row = cast(dict, await cur.fetchone())
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
version = -1
|
||||
# Create store_migrations table if it doesn't exist
|
||||
await cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS store_migrations (
|
||||
v INTEGER PRIMARY KEY
|
||||
async with _ainternal.get_connection(self.conn) as conn:
|
||||
async with conn.cursor() as cur:
|
||||
try:
|
||||
await cur.execute(
|
||||
"SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1"
|
||||
)
|
||||
"""
|
||||
)
|
||||
for v, migration in enumerate(
|
||||
self.MIGRATIONS[version + 1 :], start=version + 1
|
||||
):
|
||||
await cur.execute(migration)
|
||||
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
||||
row = cast(dict, await cur.fetchone())
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
version = -1
|
||||
# Create store_migrations table if it doesn't exist
|
||||
await cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS store_migrations (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
"""
|
||||
)
|
||||
for v, migration in enumerate(
|
||||
self.MIGRATIONS[version + 1 :], start=version + 1
|
||||
):
|
||||
await cur.execute(migration)
|
||||
await cur.execute(
|
||||
"INSERT INTO store_migrations (v) VALUES (%s)", (v,)
|
||||
)
|
||||
if self.pipe:
|
||||
await self.pipe.sync()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
@@ -18,12 +19,15 @@ from typing import (
|
||||
)
|
||||
|
||||
import orjson
|
||||
from psycopg import BaseConnection, Connection, Cursor
|
||||
from psycopg import Capabilities, Connection, Cursor, Pipeline
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
from psycopg_pool import ConnectionPool
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.checkpoint.postgres import _ainternal as _ainternal
|
||||
from langgraph.checkpoint.postgres import _internal as _pg_internal
|
||||
from langgraph.store.base import (
|
||||
BaseStore,
|
||||
GetOp,
|
||||
@@ -56,7 +60,32 @@ CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pa
|
||||
""",
|
||||
]
|
||||
|
||||
C = TypeVar("C", bound=BaseConnection)
|
||||
C = TypeVar("C", bound=Union[_pg_internal.Conn, _ainternal.Conn])
|
||||
|
||||
|
||||
class PoolConfig(TypedDict, total=False):
|
||||
"""Connection pool settings for PostgreSQL connections.
|
||||
|
||||
Controls connection lifecycle and resource utilization:
|
||||
- Small pools (1-5) suit low-concurrency workloads
|
||||
- Larger pools handle concurrent requests but consume more resources
|
||||
- Setting max_size prevents resource exhaustion under load
|
||||
"""
|
||||
|
||||
min_size: int
|
||||
"""Minimum number of connections maintained in the pool. Defaults to 1."""
|
||||
|
||||
max_size: Optional[int]
|
||||
"""Maximum number of connections allowed in the pool. None means unlimited."""
|
||||
|
||||
kwargs: dict
|
||||
"""Additional connection arguments passed to each connection in the pool.
|
||||
|
||||
Default kwargs set automatically:
|
||||
- autocommit: True
|
||||
- prepare_threshold: 0
|
||||
- row_factory: dict_row
|
||||
"""
|
||||
|
||||
|
||||
class BasePostgresStore(Generic[C]):
|
||||
@@ -88,9 +117,14 @@ class BasePostgresStore(Generic[C]):
|
||||
self,
|
||||
put_ops: Sequence[tuple[int, PutOp]],
|
||||
) -> list[tuple[str, Sequence]]:
|
||||
# Last-write wins
|
||||
dedupped_ops: dict[tuple[tuple[str, ...], str], PutOp] = {}
|
||||
for _, op in put_ops:
|
||||
dedupped_ops[(op.namespace, op.key)] = op
|
||||
|
||||
inserts: list[PutOp] = []
|
||||
deletes: list[PutOp] = []
|
||||
for _, op in put_ops:
|
||||
for op in dedupped_ops.values():
|
||||
if op.value is None:
|
||||
deletes.append(op)
|
||||
else:
|
||||
@@ -219,13 +253,14 @@ class BasePostgresStore(Generic[C]):
|
||||
return queries
|
||||
|
||||
|
||||
class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
__slots__ = ("_deserializer",)
|
||||
class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
||||
__slots__ = ("_deserializer", "pipe", "lock", "supports_pipeline")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Connection[Any],
|
||||
conn: _pg_internal.Conn,
|
||||
*,
|
||||
pipe: Optional[Pipeline] = None,
|
||||
deserializer: Optional[
|
||||
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
||||
] = None,
|
||||
@@ -233,26 +268,110 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
super().__init__()
|
||||
self._deserializer = deserializer
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
*,
|
||||
pipeline: bool = False,
|
||||
pool_config: Optional[PoolConfig] = None,
|
||||
) -> Iterator["PostgresStore"]:
|
||||
"""Create a new PostgresStore instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
pipeline (bool): whether to use Pipeline (only for single connections)
|
||||
pool_config (Optional[PoolArgs]): Configuration for the connection pool.
|
||||
If provided, will create a connection pool and use it instead of a single connection.
|
||||
This overrides the `pipeline` argument.
|
||||
Returns:
|
||||
PostgresStore: A new PostgresStore instance.
|
||||
"""
|
||||
if pool_config is not None:
|
||||
pc = pool_config.copy()
|
||||
with cast(
|
||||
ConnectionPool[Connection[DictRow]],
|
||||
ConnectionPool(
|
||||
conn_string,
|
||||
min_size=pc.pop("min_size", 1),
|
||||
max_size=pc.pop("max_size", None),
|
||||
kwargs={
|
||||
"autocommit": True,
|
||||
"prepare_threshold": 0,
|
||||
"row_factory": dict_row,
|
||||
**(pc.pop("kwargs", None) or {}),
|
||||
},
|
||||
**cast(dict, pc),
|
||||
),
|
||||
) as pool:
|
||||
yield cls(conn=pool)
|
||||
else:
|
||||
with Connection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
if pipeline:
|
||||
with conn.pipeline() as pipe:
|
||||
yield cls(conn, pipe=pipe)
|
||||
else:
|
||||
yield cls(conn)
|
||||
|
||||
@contextmanager
|
||||
def _cursor(self, *, pipeline: bool = False) -> Iterator[Cursor[DictRow]]:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
pipeline (bool): whether to use pipeline for the DB operations inside the context manager.
|
||||
Will be applied regardless of whether the PostgresStore instance was initialized with a pipeline.
|
||||
If pipeline mode is not supported, will fall back to using transaction context manager.
|
||||
"""
|
||||
with _pg_internal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
# in multiple threads/coroutines, but only one cursor can be
|
||||
# used at a time
|
||||
try:
|
||||
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
finally:
|
||||
if pipeline:
|
||||
self.pipe.sync()
|
||||
elif pipeline:
|
||||
# a connection not in pipeline mode can only be used by one
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
if self.supports_pipeline:
|
||||
with self.lock, conn.pipeline(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
with self.lock, conn.transaction(), conn.cursor(
|
||||
binary=True, row_factory=dict_row
|
||||
) as cur:
|
||||
yield cur
|
||||
else:
|
||||
with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
yield cur
|
||||
|
||||
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
grouped_ops, num_ops = _group_ops(ops)
|
||||
results: list[Result] = [None] * num_ops
|
||||
|
||||
with self.conn.pipeline():
|
||||
with self._cursor(pipeline=True) as cur:
|
||||
if GetOp in grouped_ops:
|
||||
self._batch_get_ops(
|
||||
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results
|
||||
)
|
||||
|
||||
if PutOp in grouped_ops:
|
||||
self._batch_put_ops(
|
||||
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp])
|
||||
cast(Sequence[tuple[int, GetOp]], grouped_ops[GetOp]), results, cur
|
||||
)
|
||||
|
||||
if SearchOp in grouped_ops:
|
||||
self._batch_search_ops(
|
||||
cast(Sequence[tuple[int, SearchOp]], grouped_ops[SearchOp]),
|
||||
results,
|
||||
cur,
|
||||
)
|
||||
|
||||
if ListNamespacesOp in grouped_ops:
|
||||
@@ -262,25 +381,23 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
grouped_ops[ListNamespacesOp],
|
||||
),
|
||||
results,
|
||||
cur,
|
||||
)
|
||||
if PutOp in grouped_ops:
|
||||
self._batch_put_ops(
|
||||
cast(Sequence[tuple[int, PutOp]], grouped_ops[PutOp]), cur
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
|
||||
|
||||
def _batch_get_ops(
|
||||
self,
|
||||
get_ops: Sequence[tuple[int, GetOp]],
|
||||
results: list[Result],
|
||||
cur: Cursor[DictRow],
|
||||
) -> None:
|
||||
cursors = []
|
||||
for query, params, namespace, items in self._get_batch_GET_ops_queries(get_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
cur.execute(query, params)
|
||||
cursors.append((cur, namespace, items))
|
||||
|
||||
for cur, namespace, items in cursors:
|
||||
rows = cast(list[Row], cur.fetchall())
|
||||
key_to_row = {row["key"]: row for row in rows}
|
||||
for idx, key in items:
|
||||
@@ -295,70 +412,44 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
def _batch_put_ops(
|
||||
self,
|
||||
put_ops: Sequence[tuple[int, PutOp]],
|
||||
cur: Cursor[DictRow],
|
||||
) -> None:
|
||||
queries = self._get_batch_PUT_queries(put_ops)
|
||||
for query, params in queries:
|
||||
cur = self.conn.cursor(binary=True)
|
||||
cur.execute(query, params)
|
||||
|
||||
def _batch_search_ops(
|
||||
self,
|
||||
search_ops: Sequence[tuple[int, SearchOp]],
|
||||
results: list[Result],
|
||||
cur: Cursor[DictRow],
|
||||
) -> None:
|
||||
queries = self._get_batch_search_queries(search_ops)
|
||||
cursors: list[tuple[Cursor[Any], int]] = []
|
||||
|
||||
for (query, params), (idx, _) in zip(queries, search_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
for (query, params), (idx, _) in zip(
|
||||
self._get_batch_search_queries(search_ops), search_ops
|
||||
):
|
||||
cur.execute(query, params)
|
||||
cursors.append((cur, idx))
|
||||
|
||||
for cur, idx in cursors:
|
||||
rows = cast(list[Row], cur.fetchall())
|
||||
items = [
|
||||
results[idx] = [
|
||||
_row_to_item(
|
||||
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
results[idx] = items
|
||||
|
||||
def _batch_list_namespaces_ops(
|
||||
self,
|
||||
list_ops: Sequence[tuple[int, ListNamespacesOp]],
|
||||
results: list[Result],
|
||||
cur: Cursor[DictRow],
|
||||
) -> None:
|
||||
queries = self._get_batch_list_namespaces_queries(list_ops)
|
||||
cursors: list[tuple[Cursor[Any], int]] = []
|
||||
for (query, params), (idx, _) in zip(queries, list_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
for (query, params), (idx, _) in zip(
|
||||
self._get_batch_list_namespaces_queries(list_ops), list_ops
|
||||
):
|
||||
cur.execute(query, params)
|
||||
cursors.append((cur, idx))
|
||||
results[idx] = [_decode_ns_bytes(row["truncated_prefix"]) for row in cur]
|
||||
|
||||
for cur, idx in cursors:
|
||||
rows = cast(list[dict], cur.fetchall())
|
||||
namespaces = [_decode_ns_bytes(row["truncated_prefix"]) for row in rows]
|
||||
results[idx] = namespaces
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
) -> Iterator["PostgresStore"]:
|
||||
"""Create a new BasePostgresStore instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
|
||||
Returns:
|
||||
BasePostgresStore: A new BasePostgresStore instance.
|
||||
"""
|
||||
with Connection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
yield cls(conn=conn)
|
||||
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return await asyncio.get_running_loop().run_in_executor(None, self.batch, ops)
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the store database.
|
||||
@@ -367,7 +458,7 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
already exist and runs database migrations. It MUST be called directly by the user
|
||||
the first time the store is used.
|
||||
"""
|
||||
with self.conn.cursor(binary=True) as cur:
|
||||
with self._cursor() as cur:
|
||||
try:
|
||||
cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
|
||||
row = cast(dict, cur.fetchone())
|
||||
@@ -376,9 +467,7 @@ class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
self.conn.rollback()
|
||||
version = -1
|
||||
# Create store_migrations table if it doesn't exist
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS store_migrations (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.2"
|
||||
version = "2.0.4"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -24,6 +24,10 @@ async def clear_test_db(conn: AsyncConnection[DictRow]) -> None:
|
||||
await conn.execute("DELETE FROM checkpoint_blobs")
|
||||
await conn.execute("DELETE FROM checkpoint_writes")
|
||||
await conn.execute("DELETE FROM checkpoint_migrations")
|
||||
await conn.execute("DELETE FROM store_migrations")
|
||||
except UndefinedTable:
|
||||
pass
|
||||
try:
|
||||
await conn.execute("DELETE FROM store_migrations")
|
||||
await conn.execute("DELETE FROM store")
|
||||
except UndefinedTable:
|
||||
pass
|
||||
|
||||
@@ -1,114 +1,76 @@
|
||||
# type: ignore
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from conftest import DEFAULT_URI # type: ignore
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp
|
||||
from langgraph.store.postgres import AsyncPostgresStore
|
||||
|
||||
|
||||
class MockAsyncCursor:
|
||||
def __init__(self, fetch_result: Any) -> None:
|
||||
self.fetch_result = fetch_result
|
||||
self.execute = AsyncMock()
|
||||
self.fetchall = AsyncMock(return_value=self.fetch_result)
|
||||
@pytest.fixture(scope="function", params=["default", "pipe", "pool"])
|
||||
async def store(request) -> AsyncIterator[AsyncPostgresStore]:
|
||||
if sys.version_info < (3, 10):
|
||||
pytest.skip("Async Postgres tests require Python 3.10+")
|
||||
|
||||
database = f"test_{uuid.uuid4().hex[:16]}"
|
||||
uri_parts = DEFAULT_URI.split("/")
|
||||
uri_base = "/".join(uri_parts[:-1])
|
||||
query_params = ""
|
||||
if "?" in uri_parts[-1]:
|
||||
db_name, query_params = uri_parts[-1].split("?", 1)
|
||||
query_params = "?" + query_params
|
||||
|
||||
class MockAsyncConnection:
|
||||
def __init__(self) -> None:
|
||||
self.cursor = MagicMock()
|
||||
self.pipeline = MagicMock(
|
||||
return_value=AsyncMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())
|
||||
)
|
||||
conn_string = f"{uri_base}/{database}{query_params}"
|
||||
admin_conn_string = DEFAULT_URI
|
||||
|
||||
async with await AsyncConnection.connect(
|
||||
admin_conn_string, autocommit=True
|
||||
) as conn:
|
||||
await conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
async with AsyncPostgresStore.from_conn_string(conn_string) as store:
|
||||
await store.setup()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockAsyncConnection:
|
||||
return MockAsyncConnection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(mock_connection: MockAsyncConnection) -> AsyncPostgresStore:
|
||||
return AsyncPostgresStore(mock_connection)
|
||||
if request.param == "pipe":
|
||||
async with AsyncPostgresStore.from_conn_string(
|
||||
conn_string, pipeline=True
|
||||
) as store:
|
||||
yield store
|
||||
elif request.param == "pool":
|
||||
async with AsyncPostgresStore.from_conn_string(
|
||||
conn_string, pool_config={"min_size": 1, "max_size": 10}
|
||||
) as store:
|
||||
yield store
|
||||
else: # default
|
||||
async with AsyncPostgresStore.from_conn_string(conn_string) as store:
|
||||
yield store
|
||||
finally:
|
||||
async with await AsyncConnection.connect(
|
||||
admin_conn_string, autocommit=True
|
||||
) as conn:
|
||||
await conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
async def test_abatch_order(store: AsyncPostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_get_cursor = MockAsyncCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"value": '{"data": "value2"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.bar",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_search_cursor = MockAsyncCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_list_namespaces_cursor = MockAsyncCursor(
|
||||
[
|
||||
{"truncated_prefix": b"\x01test"},
|
||||
]
|
||||
)
|
||||
|
||||
failures = []
|
||||
|
||||
def cursor_side_effect(binary: bool = False) -> Any:
|
||||
cursor = MagicMock()
|
||||
|
||||
async def execute_side_effect(query: str, *params: Any) -> None:
|
||||
# My super sophisticated database.
|
||||
if "SELECT prefix, key," in query:
|
||||
cursor.fetchall = mock_search_cursor.fetchall
|
||||
elif "SELECT DISTINCT ON (truncated_prefix)" in query:
|
||||
cursor.fetchall = mock_list_namespaces_cursor.fetchall
|
||||
elif "WHERE prefix = %s AND key" in query:
|
||||
cursor.fetchall = mock_get_cursor.fetchall
|
||||
elif "INSERT INTO " in query:
|
||||
pass
|
||||
else:
|
||||
e = ValueError(f"Unmatched query: {query}")
|
||||
failures.append(e)
|
||||
raise e
|
||||
|
||||
cursor.execute = AsyncMock(side_effect=execute_side_effect)
|
||||
return cursor
|
||||
|
||||
mock_connection.cursor.side_effect = cursor_side_effect # type: ignore
|
||||
# Setup test data
|
||||
await store.aput(("test", "foo"), "key1", {"data": "value1"})
|
||||
await store.aput(("test", "bar"), "key2", {"data": "value2"})
|
||||
|
||||
ops = [
|
||||
GetOp(namespace=("test",), key="key1"),
|
||||
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
|
||||
GetOp(namespace=("test", "foo"), key="key1"),
|
||||
PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}),
|
||||
SearchOp(
|
||||
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
|
||||
),
|
||||
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
|
||||
GetOp(namespace=("test",), key="key3"),
|
||||
]
|
||||
|
||||
results = await store.abatch(ops)
|
||||
assert not failures
|
||||
assert len(results) == 5
|
||||
assert isinstance(results[0], Item)
|
||||
assert isinstance(results[0].value, dict)
|
||||
@@ -118,27 +80,29 @@ async def test_abatch_order(store: AsyncPostgresStore) -> None:
|
||||
assert isinstance(results[2], list)
|
||||
assert len(results[2]) == 1
|
||||
assert isinstance(results[3], list)
|
||||
assert results[3] == [("test",)]
|
||||
assert ("test", "foo") in results[3] and ("test", "bar") in results[3]
|
||||
assert results[4] is None
|
||||
|
||||
ops_reordered = [
|
||||
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
|
||||
GetOp(namespace=("test",), key="key2"),
|
||||
GetOp(namespace=("test", "bar"), key="key2"),
|
||||
ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
|
||||
PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
|
||||
GetOp(namespace=("test",), key="key1"),
|
||||
GetOp(namespace=("test", "foo"), key="key1"),
|
||||
]
|
||||
|
||||
results_reordered = await store.abatch(ops_reordered)
|
||||
assert not failures
|
||||
assert len(results_reordered) == 5
|
||||
assert isinstance(results_reordered[0], list)
|
||||
assert len(results_reordered[0]) == 1
|
||||
assert len(results_reordered[0]) == 2
|
||||
assert isinstance(results_reordered[1], Item)
|
||||
assert results_reordered[1].value == {"data": "value2"}
|
||||
assert results_reordered[1].key == "key2"
|
||||
assert isinstance(results_reordered[2], list)
|
||||
assert results_reordered[2] == [("test",)]
|
||||
assert ("test", "foo") in results_reordered[2] and (
|
||||
"test",
|
||||
"bar",
|
||||
) in results_reordered[2]
|
||||
assert results_reordered[3] is None
|
||||
assert isinstance(results_reordered[4], Item)
|
||||
assert results_reordered[4].value == {"data": "value1"}
|
||||
@@ -146,26 +110,9 @@ async def test_abatch_order(store: AsyncPostgresStore) -> None:
|
||||
|
||||
|
||||
async def test_batch_get_ops(store: AsyncPostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockAsyncCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"value": '{"data": "value2"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.bar",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
# Setup test data
|
||||
await store.aput(("test",), "key1", {"data": "value1"})
|
||||
await store.aput(("test",), "key2", {"data": "value2"})
|
||||
|
||||
ops = [
|
||||
GetOp(namespace=("test",), key="key1"),
|
||||
@@ -184,10 +131,6 @@ async def test_batch_get_ops(store: AsyncPostgresStore) -> None:
|
||||
|
||||
|
||||
async def test_batch_put_ops(store: AsyncPostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockAsyncCursor([])
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
|
||||
ops = [
|
||||
PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
|
||||
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
|
||||
@@ -198,30 +141,16 @@ async def test_batch_put_ops(store: AsyncPostgresStore) -> None:
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(result is None for result in results)
|
||||
assert mock_cursor.execute.call_count == 2
|
||||
|
||||
# Verify the puts worked
|
||||
items = await store.asearch(["test"], limit=10)
|
||||
assert len(items) == 2 # key3 had None value so wasn't stored
|
||||
|
||||
|
||||
async def test_batch_search_ops(store: AsyncPostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockAsyncCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"value": '{"data": "value2"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.bar",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
# Setup test data
|
||||
await store.aput(("test", "foo"), "key1", {"data": "value1"})
|
||||
await store.aput(("test", "bar"), "key2", {"data": "value2"})
|
||||
|
||||
ops = [
|
||||
SearchOp(
|
||||
@@ -233,29 +162,23 @@ async def test_batch_search_ops(store: AsyncPostgresStore) -> None:
|
||||
results = await store.abatch(ops)
|
||||
|
||||
assert len(results) == 2
|
||||
assert len(results[0]) == 2
|
||||
assert len(results[1]) == 2
|
||||
assert len(results[0]) == 1 # Filtered results
|
||||
assert len(results[1]) == 2 # All results
|
||||
|
||||
|
||||
async def test_batch_list_namespaces_ops(store: AsyncPostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockAsyncCursor(
|
||||
[
|
||||
{"truncated_prefix": b"\x01test.namespace1"},
|
||||
{"truncated_prefix": b"\x01test.namespace2"},
|
||||
]
|
||||
)
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
# Setup test data
|
||||
await store.aput(("test", "namespace1"), "key1", {"data": "value1"})
|
||||
await store.aput(("test", "namespace2"), "key2", {"data": "value2"})
|
||||
|
||||
ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)]
|
||||
|
||||
results = await store.abatch(ops)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] == [("test", "namespace1"), ("test", "namespace2")]
|
||||
|
||||
|
||||
# The following use the actual DB connection
|
||||
assert len(results[0]) == 2
|
||||
assert ("test", "namespace1") in results[0]
|
||||
assert ("test", "namespace2") in results[0]
|
||||
|
||||
|
||||
class TestAsyncPostgresStore:
|
||||
|
||||
@@ -1,174 +1,118 @@
|
||||
# type: ignore
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from conftest import DEFAULT_URI # type: ignore
|
||||
from psycopg import Connection
|
||||
|
||||
from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp
|
||||
from langgraph.store.base import (
|
||||
GetOp,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
PutOp,
|
||||
SearchOp,
|
||||
)
|
||||
from langgraph.store.postgres import PostgresStore
|
||||
|
||||
|
||||
class MockCursor:
|
||||
def __init__(self, fetch_result: Any) -> None:
|
||||
self.fetch_result = fetch_result
|
||||
self.execute = MagicMock()
|
||||
self.fetchall = MagicMock(return_value=self.fetch_result)
|
||||
@pytest.fixture(scope="function", params=["default", "pipe", "pool"])
|
||||
def store(request) -> PostgresStore:
|
||||
database = f"test_{uuid4().hex[:16]}"
|
||||
uri_parts = DEFAULT_URI.split("/")
|
||||
uri_base = "/".join(uri_parts[:-1])
|
||||
query_params = ""
|
||||
if "?" in uri_parts[-1]:
|
||||
db_name, query_params = uri_parts[-1].split("?", 1)
|
||||
query_params = "?" + query_params
|
||||
|
||||
conn_string = f"{uri_base}/{database}{query_params}"
|
||||
admin_conn_string = DEFAULT_URI
|
||||
|
||||
class MockConnection:
|
||||
def __init__(self) -> None:
|
||||
self.cursor = MagicMock()
|
||||
self.pipeline = MagicMock()
|
||||
with Connection.connect(admin_conn_string, autocommit=True) as conn:
|
||||
conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
with PostgresStore.from_conn_string(conn_string) as store:
|
||||
store.setup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection() -> MockConnection:
|
||||
return MockConnection()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(mock_connection: MockConnection) -> PostgresStore:
|
||||
return PostgresStore(mock_connection)
|
||||
if request.param == "pipe":
|
||||
with PostgresStore.from_conn_string(conn_string, pipeline=True) as store:
|
||||
yield store
|
||||
elif request.param == "pool":
|
||||
with PostgresStore.from_conn_string(
|
||||
conn_string, pool_config={"min_size": 1, "max_size": 10}
|
||||
) as store:
|
||||
yield store
|
||||
else: # default
|
||||
with PostgresStore.from_conn_string(conn_string) as store:
|
||||
yield store
|
||||
finally:
|
||||
with Connection.connect(admin_conn_string, autocommit=True) as conn:
|
||||
conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
def test_batch_order(store: PostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_get_cursor = MockCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"value": '{"data": "value2"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.bar",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_search_cursor = MockCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_list_namespaces_cursor = MockCursor(
|
||||
[
|
||||
{"truncated_prefix": b"\x01test"},
|
||||
]
|
||||
)
|
||||
|
||||
failures = []
|
||||
|
||||
def cursor_side_effect(binary: bool = False) -> Any:
|
||||
cursor = MagicMock()
|
||||
|
||||
def execute_side_effect(query: str, *params: Any) -> None:
|
||||
# My super sophisticated database.
|
||||
if "SELECT prefix, key, value" in query:
|
||||
cursor.fetchall = mock_search_cursor.fetchall
|
||||
elif "SELECT DISTINCT ON (truncated_prefix)" in query:
|
||||
cursor.fetchall = mock_list_namespaces_cursor.fetchall
|
||||
elif "WHERE prefix = %s AND key" in query:
|
||||
cursor.fetchall = mock_get_cursor.fetchall
|
||||
elif "INSERT INTO " in query:
|
||||
pass
|
||||
else:
|
||||
e = ValueError(f"Unmatched query: {query}")
|
||||
failures.append(e)
|
||||
raise e
|
||||
|
||||
cursor.execute = MagicMock(side_effect=execute_side_effect)
|
||||
return cursor
|
||||
|
||||
mock_connection.cursor.side_effect = cursor_side_effect
|
||||
# Setup test data
|
||||
store.put(("test", "foo"), "key1", {"data": "value1"})
|
||||
store.put(("test", "bar"), "key2", {"data": "value2"})
|
||||
|
||||
ops = [
|
||||
GetOp(namespace=("test",), key="key1"),
|
||||
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
|
||||
GetOp(namespace=("test", "foo"), key="key1"),
|
||||
PutOp(namespace=("test", "bar"), key="key2", value={"data": "value2"}),
|
||||
SearchOp(
|
||||
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
|
||||
),
|
||||
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
|
||||
GetOp(namespace=("test",), key="key3"),
|
||||
]
|
||||
|
||||
results = store.batch(ops)
|
||||
assert not failures
|
||||
assert len(results) == 5
|
||||
assert isinstance(results[0], Item)
|
||||
assert isinstance(results[0].value, dict)
|
||||
assert results[0].value == {"data": "value1"}
|
||||
assert results[0].key == "key1"
|
||||
assert results[1] is None
|
||||
assert results[1] is None # Put operation returns None
|
||||
assert isinstance(results[2], list)
|
||||
assert len(results[2]) == 1
|
||||
assert isinstance(results[3], list)
|
||||
assert results[3] == [("test",)]
|
||||
assert results[4] is None
|
||||
assert len(results[3]) > 0 # Should contain at least our test namespaces
|
||||
assert results[4] is None # Non-existent key returns None
|
||||
|
||||
# Test reordered operations
|
||||
ops_reordered = [
|
||||
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
|
||||
GetOp(namespace=("test",), key="key2"),
|
||||
GetOp(namespace=("test", "bar"), key="key2"),
|
||||
ListNamespacesOp(match_conditions=None, max_depth=None, limit=5, offset=0),
|
||||
PutOp(namespace=("test",), key="key3", value={"data": "value3"}),
|
||||
GetOp(namespace=("test",), key="key1"),
|
||||
GetOp(namespace=("test", "foo"), key="key1"),
|
||||
]
|
||||
|
||||
results_reordered = store.batch(ops_reordered)
|
||||
assert not failures
|
||||
assert len(results_reordered) == 5
|
||||
assert isinstance(results_reordered[0], list)
|
||||
assert len(results_reordered[0]) == 1
|
||||
assert len(results_reordered[0]) >= 2 # Should find at least our two test items
|
||||
assert isinstance(results_reordered[1], Item)
|
||||
assert results_reordered[1].value == {"data": "value2"}
|
||||
assert results_reordered[1].key == "key2"
|
||||
assert isinstance(results_reordered[2], list)
|
||||
assert results_reordered[2] == [("test",)]
|
||||
assert results_reordered[3] is None
|
||||
assert len(results_reordered[2]) > 0
|
||||
assert results_reordered[3] is None # Put operation returns None
|
||||
assert isinstance(results_reordered[4], Item)
|
||||
assert results_reordered[4].value == {"data": "value1"}
|
||||
assert results_reordered[4].key == "key1"
|
||||
|
||||
|
||||
def test_batch_get_ops(store: PostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"value": '{"data": "value2"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.bar",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
# Setup test data
|
||||
store.put(("test",), "key1", {"data": "value1"})
|
||||
store.put(("test",), "key2", {"data": "value2"})
|
||||
|
||||
ops = [
|
||||
GetOp(namespace=("test",), key="key1"),
|
||||
GetOp(namespace=("test",), key="key2"),
|
||||
GetOp(namespace=("test",), key="key3"),
|
||||
GetOp(namespace=("test",), key="key3"), # Non-existent key
|
||||
]
|
||||
|
||||
results = store.batch(ops)
|
||||
@@ -182,75 +126,90 @@ def test_batch_get_ops(store: PostgresStore) -> None:
|
||||
|
||||
|
||||
def test_batch_put_ops(store: PostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockCursor([])
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
|
||||
ops = [
|
||||
PutOp(namespace=("test",), key="key1", value={"data": "value1"}),
|
||||
PutOp(namespace=("test",), key="key2", value={"data": "value2"}),
|
||||
PutOp(namespace=("test",), key="key3", value=None),
|
||||
PutOp(namespace=("test",), key="key3", value=None), # Delete operation
|
||||
]
|
||||
|
||||
results = store.batch(ops)
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(result is None for result in results)
|
||||
assert mock_cursor.execute.call_count == 2
|
||||
|
||||
# Verify the puts worked
|
||||
item1 = store.get(("test",), "key1")
|
||||
item2 = store.get(("test",), "key2")
|
||||
item3 = store.get(("test",), "key3")
|
||||
|
||||
assert item1 and item1.value == {"data": "value1"}
|
||||
assert item2 and item2.value == {"data": "value2"}
|
||||
assert item3 is None
|
||||
|
||||
|
||||
def test_batch_search_ops(store: PostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockCursor(
|
||||
[
|
||||
{
|
||||
"key": "key1",
|
||||
"value": '{"data": "value1"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.foo",
|
||||
},
|
||||
{
|
||||
"key": "key2",
|
||||
"value": '{"data": "value2"}',
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now(),
|
||||
"prefix": "test.bar",
|
||||
},
|
||||
]
|
||||
)
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
# Setup test data
|
||||
test_data = [
|
||||
(("test", "foo"), "key1", {"data": "value1", "tag": "a"}),
|
||||
(("test", "bar"), "key2", {"data": "value2", "tag": "a"}),
|
||||
(("test", "baz"), "key3", {"data": "value3", "tag": "b"}),
|
||||
]
|
||||
for namespace, key, value in test_data:
|
||||
store.put(namespace, key, value)
|
||||
|
||||
ops = [
|
||||
SearchOp(
|
||||
namespace_prefix=("test",), filter={"data": "value1"}, limit=10, offset=0
|
||||
),
|
||||
SearchOp(namespace_prefix=("test",), filter=None, limit=5, offset=0),
|
||||
SearchOp(namespace_prefix=("test",), filter={"tag": "a"}, limit=10, offset=0),
|
||||
SearchOp(namespace_prefix=("test",), filter=None, limit=2, offset=0),
|
||||
SearchOp(namespace_prefix=("test", "foo"), filter=None, limit=10, offset=0),
|
||||
]
|
||||
|
||||
results = store.batch(ops)
|
||||
assert len(results) == 3
|
||||
|
||||
assert len(results) == 2
|
||||
# First search should find items with tag "a"
|
||||
assert len(results[0]) == 2
|
||||
assert all(item.value["tag"] == "a" for item in results[0])
|
||||
|
||||
# Second search should return first 2 items
|
||||
assert len(results[1]) == 2
|
||||
|
||||
# Third search should only find items in test/foo namespace
|
||||
assert len(results[2]) == 1
|
||||
assert results[2][0].namespace == ("test", "foo")
|
||||
|
||||
|
||||
def test_batch_list_namespaces_ops(store: PostgresStore) -> None:
|
||||
mock_connection = store.conn
|
||||
mock_cursor = MockCursor(
|
||||
[
|
||||
{"truncated_prefix": b"\x01test.namespace1"},
|
||||
{"truncated_prefix": b"\x01test.namespace2"},
|
||||
]
|
||||
)
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
# Setup test data with various namespaces
|
||||
test_data = [
|
||||
(("test", "documents", "public"), "doc1", {"content": "public doc"}),
|
||||
(("test", "documents", "private"), "doc2", {"content": "private doc"}),
|
||||
(("test", "images", "public"), "img1", {"content": "public image"}),
|
||||
(("prod", "documents", "public"), "doc3", {"content": "prod doc"}),
|
||||
]
|
||||
for namespace, key, value in test_data:
|
||||
store.put(namespace, key, value)
|
||||
|
||||
ops = [ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0)]
|
||||
ops = [
|
||||
ListNamespacesOp(match_conditions=None, max_depth=None, limit=10, offset=0),
|
||||
ListNamespacesOp(match_conditions=None, max_depth=2, limit=10, offset=0),
|
||||
ListNamespacesOp(
|
||||
match_conditions=[MatchCondition("suffix", "public")],
|
||||
max_depth=None,
|
||||
limit=10,
|
||||
offset=0,
|
||||
),
|
||||
]
|
||||
|
||||
results = store.batch(ops)
|
||||
assert len(results) == 3
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0] == [("test", "namespace1"), ("test", "namespace2")]
|
||||
# First operation should list all namespaces
|
||||
assert len(results[0]) == len(test_data)
|
||||
|
||||
# Second operation should only return namespaces up to depth 2
|
||||
assert all(len(ns) <= 2 for ns in results[1])
|
||||
|
||||
# Third operation should only return namespaces ending with "public"
|
||||
assert all(ns[-1] == "public" for ns in results[2])
|
||||
|
||||
|
||||
class TestPostgresStore:
|
||||
@@ -273,195 +232,111 @@ class TestPostgresStore:
|
||||
assert item.key == item_id
|
||||
assert item.value == item_value
|
||||
|
||||
updated_value = {
|
||||
"title": "Updated Test Document",
|
||||
"content": "Hello, LangGraph!",
|
||||
}
|
||||
# Test update
|
||||
updated_value = {"title": "Updated Document", "content": "Hello, Updated!"}
|
||||
store.put(namespace, item_id, updated_value)
|
||||
updated_item = store.get(namespace, item_id)
|
||||
|
||||
assert updated_item.value == updated_value
|
||||
assert updated_item.updated_at > item.updated_at
|
||||
|
||||
# Test get from non-existent namespace
|
||||
different_namespace = ("test", "other_documents")
|
||||
item_in_different_namespace = store.get(different_namespace, item_id)
|
||||
assert item_in_different_namespace is None
|
||||
|
||||
new_item_id = "doc2"
|
||||
new_item_value = {"title": "Another Document", "content": "Greetings!"}
|
||||
store.put(namespace, new_item_id, new_item_value)
|
||||
|
||||
search_results = store.search(["test"], limit=10)
|
||||
items = search_results
|
||||
assert len(items) == 2
|
||||
assert any(item.key == item_id for item in items)
|
||||
assert any(item.key == new_item_id for item in items)
|
||||
|
||||
namespaces = store.list_namespaces(prefix=["test"])
|
||||
assert ("test", "documents") in namespaces
|
||||
|
||||
# Test delete
|
||||
store.delete(namespace, item_id)
|
||||
store.delete(namespace, new_item_id)
|
||||
deleted_item = store.get(namespace, item_id)
|
||||
assert deleted_item is None
|
||||
|
||||
deleted_item = store.get(namespace, new_item_id)
|
||||
assert deleted_item is None
|
||||
|
||||
empty_search_results = store.search(["test"], limit=10)
|
||||
assert len(empty_search_results) == 0
|
||||
|
||||
def test_list_namespaces(self) -> None:
|
||||
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
|
||||
test_pref = str(uuid.uuid4())
|
||||
# Create test data with various namespaces
|
||||
test_namespaces = [
|
||||
(test_pref, "test", "documents", "public", test_pref),
|
||||
(test_pref, "test", "documents", "private", test_pref),
|
||||
(test_pref, "test", "images", "public", test_pref),
|
||||
(test_pref, "test", "images", "private", test_pref),
|
||||
(test_pref, "prod", "documents", "public", test_pref),
|
||||
(
|
||||
test_pref,
|
||||
"prod",
|
||||
"documents",
|
||||
"some",
|
||||
"nesting",
|
||||
"public",
|
||||
test_pref,
|
||||
),
|
||||
(test_pref, "prod", "documents", "private", test_pref),
|
||||
("test", "documents", "public"),
|
||||
("test", "documents", "private"),
|
||||
("test", "images", "public"),
|
||||
("test", "images", "private"),
|
||||
("prod", "documents", "public"),
|
||||
("prod", "documents", "private"),
|
||||
]
|
||||
|
||||
# Insert test data
|
||||
for namespace in test_namespaces:
|
||||
store.put(namespace, "dummy", {"content": "dummy"})
|
||||
|
||||
prefix_result = store.list_namespaces(prefix=[test_pref, "test"])
|
||||
assert len(prefix_result) == 4
|
||||
assert all([ns[1] == "test" for ns in prefix_result])
|
||||
# Test listing with various filters
|
||||
all_namespaces = store.list_namespaces()
|
||||
assert len(all_namespaces) == len(test_namespaces)
|
||||
|
||||
specific_prefix_result = store.list_namespaces(
|
||||
prefix=[test_pref, "test", "documents"]
|
||||
)
|
||||
assert len(specific_prefix_result) == 2
|
||||
assert all(
|
||||
[ns[1:3] == ("test", "documents") for ns in specific_prefix_result]
|
||||
)
|
||||
# Test prefix filtering
|
||||
test_prefix_namespaces = store.list_namespaces(prefix=["test"])
|
||||
assert len(test_prefix_namespaces) == 4
|
||||
assert all(ns[0] == "test" for ns in test_prefix_namespaces)
|
||||
|
||||
suffix_result = store.list_namespaces(suffix=["public", test_pref])
|
||||
assert len(suffix_result) == 4
|
||||
assert all(ns[-2] == "public" for ns in suffix_result)
|
||||
# Test suffix filtering
|
||||
public_namespaces = store.list_namespaces(suffix=["public"])
|
||||
assert len(public_namespaces) == 3
|
||||
assert all(ns[-1] == "public" for ns in public_namespaces)
|
||||
|
||||
prefix_suffix_result = store.list_namespaces(
|
||||
prefix=[test_pref, "test"], suffix=["public", test_pref]
|
||||
)
|
||||
assert len(prefix_suffix_result) == 2
|
||||
assert all(
|
||||
ns[1] == "test" and ns[-2] == "public" for ns in prefix_suffix_result
|
||||
)
|
||||
# Test max depth
|
||||
depth_2_namespaces = store.list_namespaces(max_depth=2)
|
||||
assert all(len(ns) <= 2 for ns in depth_2_namespaces)
|
||||
|
||||
wildcard_prefix_result = store.list_namespaces(
|
||||
prefix=[test_pref, "*", "documents"]
|
||||
)
|
||||
assert len(wildcard_prefix_result) == 5
|
||||
assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
|
||||
|
||||
wildcard_suffix_result = store.list_namespaces(
|
||||
suffix=["*", "public", test_pref]
|
||||
)
|
||||
assert len(wildcard_suffix_result) == 4
|
||||
assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
|
||||
wildcard_single = store.list_namespaces(
|
||||
suffix=["some", "*", "public", test_pref]
|
||||
)
|
||||
assert len(wildcard_single) == 1
|
||||
assert wildcard_single[0] == (
|
||||
test_pref,
|
||||
"prod",
|
||||
"documents",
|
||||
"some",
|
||||
"nesting",
|
||||
"public",
|
||||
test_pref,
|
||||
)
|
||||
|
||||
max_depth_result = store.list_namespaces(max_depth=3)
|
||||
assert all([len(ns) <= 3 for ns in max_depth_result])
|
||||
|
||||
max_depth_result = store.list_namespaces(
|
||||
max_depth=4, prefix=[test_pref, "*", "documents"]
|
||||
)
|
||||
assert (
|
||||
len(set(tuple(res) for res in max_depth_result))
|
||||
== len(max_depth_result)
|
||||
== 5
|
||||
)
|
||||
|
||||
limit_result = store.list_namespaces(prefix=[test_pref], limit=3)
|
||||
assert len(limit_result) == 3
|
||||
|
||||
offset_result = store.list_namespaces(prefix=[test_pref], offset=3)
|
||||
assert len(offset_result) == len(test_namespaces) - 3
|
||||
|
||||
empty_prefix_result = store.list_namespaces(prefix=[test_pref])
|
||||
assert len(empty_prefix_result) == len(test_namespaces)
|
||||
assert set(tuple(ns) for ns in empty_prefix_result) == set(
|
||||
tuple(ns) for ns in test_namespaces
|
||||
)
|
||||
# Test pagination
|
||||
paginated_namespaces = store.list_namespaces(limit=3)
|
||||
assert len(paginated_namespaces) == 3
|
||||
|
||||
# Cleanup
|
||||
for namespace in test_namespaces:
|
||||
store.delete(namespace, "dummy")
|
||||
|
||||
def test_search(self):
|
||||
def test_search(self) -> None:
|
||||
with PostgresStore.from_conn_string(DEFAULT_URI) as store:
|
||||
test_namespaces = [
|
||||
("test_search", "documents", "user1"),
|
||||
("test_search", "documents", "user2"),
|
||||
("test_search", "reports", "department1"),
|
||||
("test_search", "reports", "department2"),
|
||||
]
|
||||
test_items = [
|
||||
{"title": "Doc 1", "author": "John Doe", "tags": ["important"]},
|
||||
{"title": "Doc 2", "author": "Jane Smith", "tags": ["draft"]},
|
||||
{"title": "Report A", "author": "John Doe", "tags": ["final"]},
|
||||
{"title": "Report B", "author": "Alice Johnson", "tags": ["draft"]},
|
||||
# Create test data
|
||||
test_data = [
|
||||
(
|
||||
("test", "docs"),
|
||||
"doc1",
|
||||
{"title": "First Doc", "author": "Alice", "tags": ["important"]},
|
||||
),
|
||||
(
|
||||
("test", "docs"),
|
||||
"doc2",
|
||||
{"title": "Second Doc", "author": "Bob", "tags": ["draft"]},
|
||||
),
|
||||
(
|
||||
("test", "images"),
|
||||
"img1",
|
||||
{"title": "Image 1", "author": "Alice", "tags": ["final"]},
|
||||
),
|
||||
]
|
||||
|
||||
for namespace, item in zip(test_namespaces, test_items):
|
||||
store.put(namespace, f"item_{namespace[-1]}", item)
|
||||
for namespace, key, value in test_data:
|
||||
store.put(namespace, key, value)
|
||||
|
||||
docs_result = store.search(["test_search", "documents"])
|
||||
assert len(docs_result) == 2
|
||||
assert all(
|
||||
[item.namespace[1] == "documents" for item in docs_result]
|
||||
), docs_result
|
||||
# Test basic search
|
||||
all_items = store.search(["test"])
|
||||
assert len(all_items) == 3
|
||||
|
||||
reports_result = store.search(["test_search", "reports"])
|
||||
assert len(reports_result) == 2
|
||||
assert all(item.namespace[1] == "reports" for item in reports_result)
|
||||
# Test namespace filtering
|
||||
docs_items = store.search(["test", "docs"])
|
||||
assert len(docs_items) == 2
|
||||
assert all(item.namespace == ("test", "docs") for item in docs_items)
|
||||
|
||||
limited_result = store.search(["test_search"], limit=2)
|
||||
assert len(limited_result) == 2
|
||||
offset_result = store.search(["test_search"])
|
||||
assert len(offset_result) == 4
|
||||
# Test value filtering
|
||||
alice_items = store.search(["test"], filter={"author": "Alice"})
|
||||
assert len(alice_items) == 2
|
||||
assert all(item.value["author"] == "Alice" for item in alice_items)
|
||||
|
||||
offset_result = store.search(["test_search"], offset=2)
|
||||
assert len(offset_result) == 2
|
||||
assert all(item not in limited_result for item in offset_result)
|
||||
# Test pagination
|
||||
paginated_items = store.search(["test"], limit=2)
|
||||
assert len(paginated_items) == 2
|
||||
|
||||
john_doe_result = store.search(
|
||||
["test_search"], filter={"author": "John Doe"}
|
||||
)
|
||||
assert len(john_doe_result) == 2
|
||||
assert all(item.value["author"] == "John Doe" for item in john_doe_result)
|
||||
offset_items = store.search(["test"], offset=2)
|
||||
assert len(offset_items) == 1
|
||||
|
||||
draft_result = store.search(["test_search"], filter={"tags": ["draft"]})
|
||||
assert len(draft_result) == 2
|
||||
assert all("draft" in item.value["tags"] for item in draft_result)
|
||||
|
||||
page1 = store.search(["test_search"], limit=2, offset=0)
|
||||
page2 = store.search(["test_search"], limit=2, offset=2)
|
||||
all_items = page1 + page2
|
||||
assert len(all_items) == 4
|
||||
assert len(set(item.key for item in all_items)) == 4
|
||||
|
||||
for namespace in test_namespaces:
|
||||
store.delete(namespace, f"item_{namespace[-1]}")
|
||||
# Cleanup
|
||||
for namespace, key, _ in test_data:
|
||||
store.delete(namespace, key)
|
||||
|
||||
@@ -24,6 +24,8 @@ from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_
|
||||
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
||||
from langgraph.checkpoint.serde.types import (
|
||||
ERROR,
|
||||
INTERRUPT,
|
||||
RESUME,
|
||||
SCHEDULED,
|
||||
ChannelProtocol,
|
||||
SendProtocol,
|
||||
@@ -37,12 +39,13 @@ PendingWrite = Tuple[str, str, Any]
|
||||
class CheckpointMetadata(TypedDict, total=False):
|
||||
"""Metadata associated with a checkpoint."""
|
||||
|
||||
source: Literal["input", "loop", "update"]
|
||||
source: Literal["input", "loop", "update", "fork"]
|
||||
"""The source of the checkpoint.
|
||||
|
||||
- "input": The checkpoint was created from an input to invoke/stream/batch.
|
||||
- "loop": The checkpoint was created from inside the pregel loop.
|
||||
- "update": The checkpoint was created from a manual state update.
|
||||
- "fork": The checkpoint was created as a copy of another checkpoint.
|
||||
"""
|
||||
step: int
|
||||
"""The step number of the checkpoint.
|
||||
@@ -449,4 +452,4 @@ Special writes (e.g. errors) map to negative indices, to avoid those writes from
|
||||
conflicting with regular writes.
|
||||
Each Checkpointer implementation should use this mapping in put_writes.
|
||||
"""
|
||||
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2}
|
||||
WRITES_IDX_MAP = {ERROR: -1, SCHEDULED: -2, INTERRUPT: -3, RESUME: -4}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import random
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
||||
from contextlib import AbstractAsyncContextManager, AbstractContextManager, ExitStack
|
||||
from functools import partial
|
||||
from types import TracebackType
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Tuple
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Tuple, Type
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
@@ -20,6 +24,8 @@ from langgraph.checkpoint.base import (
|
||||
)
|
||||
from langgraph.checkpoint.serde.types import TASKS, ChannelProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemorySaver(
|
||||
BaseCheckpointSaver[str], AbstractContextManager, AbstractAsyncContextManager
|
||||
@@ -68,13 +74,18 @@ class MemorySaver(
|
||||
self,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
factory: Type[defaultdict] = defaultdict,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
self.storage = defaultdict(lambda: defaultdict(dict))
|
||||
self.writes = defaultdict(dict)
|
||||
self.storage = factory(lambda: defaultdict(dict))
|
||||
self.writes = factory(dict)
|
||||
self.stack = ExitStack()
|
||||
if factory is not defaultdict:
|
||||
self.stack.enter_context(self.storage) # type: ignore[arg-type]
|
||||
self.stack.enter_context(self.writes) # type: ignore[arg-type]
|
||||
|
||||
def __enter__(self) -> "MemorySaver":
|
||||
return self
|
||||
return self.stack.__enter__()
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
@@ -82,10 +93,10 @@ class MemorySaver(
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
return
|
||||
return self.stack.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
async def __aenter__(self) -> "MemorySaver":
|
||||
return self
|
||||
return self.stack.__enter__()
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
@@ -93,7 +104,7 @@ class MemorySaver(
|
||||
__exc_value: Optional[BaseException],
|
||||
__traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
return
|
||||
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the in-memory storage.
|
||||
@@ -361,11 +372,15 @@ class MemorySaver(
|
||||
RunnableConfig: The updated config containing the saved writes' timestamp.
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
checkpoint_ns = config["configurable"]["checkpoint_ns"]
|
||||
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
|
||||
checkpoint_id = config["configurable"]["checkpoint_id"]
|
||||
outer_key = (thread_id, checkpoint_ns, checkpoint_id)
|
||||
outer_writes_ = self.writes.get(outer_key)
|
||||
for idx, (c, v) in enumerate(writes):
|
||||
inner_key = (task_id, WRITES_IDX_MAP.get(c, idx))
|
||||
if inner_key[1] >= 0 and outer_writes_ and inner_key in outer_writes_:
|
||||
continue
|
||||
|
||||
self.writes[outer_key][inner_key] = (task_id, c, self.serde.dumps_typed(v))
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
@@ -474,3 +489,76 @@ class MemorySaver(
|
||||
next_v = current_v + 1
|
||||
next_h = random.random()
|
||||
return f"{next_v:032}.{next_h:016}"
|
||||
|
||||
|
||||
class PersistentDict(defaultdict):
|
||||
"""Persistent dictionary with an API compatible with shelve and anydbm.
|
||||
|
||||
The dict is kept in memory, so the dictionary operations run as fast as
|
||||
a regular dictionary.
|
||||
|
||||
Write to disk is delayed until close or sync (similar to gdbm's fast mode).
|
||||
|
||||
Input file format is automatically discovered.
|
||||
Output file format is selectable between pickle, json, and csv.
|
||||
All three serialization formats are backed by fast C implementations.
|
||||
|
||||
Adapted from https://code.activestate.com/recipes/576642-persistent-dict-with-multiple-standard-file-format/
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, filename: str, **kwds: Any) -> None:
|
||||
self.flag = "c" # r=readonly, c=create, or n=new
|
||||
self.mode = None # None or an octal triple like 0644
|
||||
self.format = "pickle" # 'csv', 'json', or 'pickle'
|
||||
self.filename = filename
|
||||
super().__init__(*args, **kwds)
|
||||
|
||||
def sync(self) -> None:
|
||||
"Write dict to disk"
|
||||
if self.flag == "r":
|
||||
return
|
||||
tempname = self.filename + ".tmp"
|
||||
fileobj = open(tempname, "wb" if self.format == "pickle" else "w")
|
||||
try:
|
||||
self.dump(fileobj)
|
||||
except Exception:
|
||||
os.remove(tempname)
|
||||
raise
|
||||
finally:
|
||||
fileobj.close()
|
||||
shutil.move(tempname, self.filename) # atomic commit
|
||||
if self.mode is not None:
|
||||
os.chmod(self.filename, self.mode)
|
||||
|
||||
def close(self) -> None:
|
||||
self.sync()
|
||||
self.clear()
|
||||
|
||||
def __enter__(self) -> "PersistentDict":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def dump(self, fileobj: Any) -> None:
|
||||
if self.format == "pickle":
|
||||
pickle.dump(dict(self), fileobj, 2)
|
||||
else:
|
||||
raise NotImplementedError("Unknown format: " + repr(self.format))
|
||||
|
||||
def load(self) -> None:
|
||||
# try formats from most restrictive to least restrictive
|
||||
if self.flag == "n":
|
||||
return
|
||||
with open(self.filename, "rb" if self.format == "pickle" else "r") as fileobj:
|
||||
for loader in (pickle.load,):
|
||||
fileobj.seek(0)
|
||||
try:
|
||||
return self.update(loader(fileobj))
|
||||
except EOFError:
|
||||
return
|
||||
except Exception:
|
||||
logging.error(f"Failed to load file: {fileobj.name}")
|
||||
raise
|
||||
raise ValueError("File not in a supported f ormat")
|
||||
|
||||
@@ -11,6 +11,8 @@ from typing_extensions import Self
|
||||
|
||||
ERROR = "__error__"
|
||||
SCHEDULED = "__scheduled__"
|
||||
INTERRUPT = "__interrupt__"
|
||||
RESUME = "__resume__"
|
||||
TASKS = "__pregel_tasks"
|
||||
|
||||
Value = TypeVar("Value", covariant=True)
|
||||
|
||||
@@ -6,6 +6,9 @@ from langgraph.store.base import (
|
||||
BaseStore,
|
||||
GetOp,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
NameSpacePath,
|
||||
Op,
|
||||
PutOp,
|
||||
SearchOp,
|
||||
@@ -68,6 +71,74 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
self._aqueue[fut] = PutOp(namespace, key, None)
|
||||
return await fut
|
||||
|
||||
async def alist_namespaces(
|
||||
self,
|
||||
*,
|
||||
prefix: Optional[NameSpacePath] = None,
|
||||
suffix: Optional[NameSpacePath] = None,
|
||||
max_depth: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[tuple[str, ...]]:
|
||||
fut = self._loop.create_future()
|
||||
match_conditions = []
|
||||
if prefix:
|
||||
match_conditions.append(MatchCondition(match_type="prefix", path=prefix))
|
||||
if suffix:
|
||||
match_conditions.append(MatchCondition(match_type="suffix", path=suffix))
|
||||
|
||||
op = ListNamespacesOp(
|
||||
match_conditions=tuple(match_conditions),
|
||||
max_depth=max_depth,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
self._aqueue[fut] = op
|
||||
return await fut
|
||||
|
||||
|
||||
def _dedupe_ops(values: list[Op]) -> tuple[Optional[list[int]], list[Op]]:
|
||||
"""Dedupe operations while preserving order for results.
|
||||
|
||||
Args:
|
||||
values: List of operations to dedupe
|
||||
|
||||
Returns:
|
||||
Tuple of (listen indices, deduped operations)
|
||||
where listen indices map deduped operation results back to original positions
|
||||
"""
|
||||
if len(values) <= 1:
|
||||
return None, list(values)
|
||||
|
||||
dedupped: list[Op] = []
|
||||
listen: list[int] = []
|
||||
puts: dict[tuple[tuple[str, ...], str], int] = {}
|
||||
|
||||
for op in values:
|
||||
if isinstance(op, (GetOp, SearchOp, ListNamespacesOp)):
|
||||
try:
|
||||
listen.append(dedupped.index(op))
|
||||
except ValueError:
|
||||
listen.append(len(dedupped))
|
||||
dedupped.append(op)
|
||||
elif isinstance(op, PutOp):
|
||||
putkey = (op.namespace, op.key)
|
||||
if putkey in puts:
|
||||
# Overwrite previous put
|
||||
ix = puts[putkey]
|
||||
dedupped[ix] = op
|
||||
listen.append(ix)
|
||||
else:
|
||||
puts[putkey] = len(dedupped)
|
||||
listen.append(len(dedupped))
|
||||
dedupped.append(op)
|
||||
|
||||
else: # Any new ops will be treated regularly
|
||||
listen.append(len(dedupped))
|
||||
dedupped.append(op)
|
||||
|
||||
return listen, dedupped
|
||||
|
||||
|
||||
async def _run(
|
||||
aqueue: dict[asyncio.Future, Op], store: weakref.ReferenceType[BaseStore]
|
||||
@@ -81,7 +152,12 @@ async def _run(
|
||||
taken = aqueue.copy()
|
||||
# action each operation
|
||||
try:
|
||||
results = await s.abatch(taken.values())
|
||||
values = list(taken.values())
|
||||
listen, dedupped = _dedupe_ops(values)
|
||||
results = await s.abatch(dedupped)
|
||||
if listen is not None:
|
||||
results = [results[ix] for ix in listen]
|
||||
|
||||
# set the results of each operation
|
||||
for fut, result in zip(taken, results):
|
||||
fut.set_result(result)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-checkpoint"
|
||||
version = "2.0.2"
|
||||
version = "2.0.6"
|
||||
description = "Library with base interfaces for LangGraph checkpoint savers."
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -10,6 +10,18 @@ from langgraph.store.base.batch import AsyncBatchedBaseStore
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
|
||||
class MockAsyncBatchedStore(AsyncBatchedBaseStore):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._store = InMemoryStore()
|
||||
|
||||
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return self._store.batch(ops)
|
||||
|
||||
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return self._store.batch(ops)
|
||||
|
||||
|
||||
async def test_async_batch_store(mocker: MockerFixture) -> None:
|
||||
abatch = mocker.stub()
|
||||
|
||||
@@ -313,17 +325,6 @@ async def test_cannot_put_empty_namespace() -> None:
|
||||
store.delete(("langgraph", "foo"), "bar")
|
||||
assert store.get(("langgraph", "foo"), "bar") is None
|
||||
|
||||
class MockAsyncBatchedStore(AsyncBatchedBaseStore):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._store = InMemoryStore()
|
||||
|
||||
def batch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return self._store.batch(ops)
|
||||
|
||||
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
|
||||
return self._store.batch(ops)
|
||||
|
||||
async_store = MockAsyncBatchedStore()
|
||||
doc = {"foo": "bar"}
|
||||
|
||||
@@ -340,13 +341,82 @@ async def test_cannot_put_empty_namespace() -> None:
|
||||
await async_store.aput(("langgraph", "foo"), "bar", doc)
|
||||
|
||||
await async_store.aput(("foo", "langgraph", "foo"), "bar", doc)
|
||||
assert (await async_store.aget(("foo", "langgraph", "foo"), "bar")).value == doc
|
||||
val = await async_store.aget(("foo", "langgraph", "foo"), "bar")
|
||||
assert val is not None
|
||||
assert val.value == doc
|
||||
assert (await async_store.asearch(("foo", "langgraph", "foo")))[0].value == doc
|
||||
await async_store.adelete(("foo", "langgraph", "foo"), "bar")
|
||||
assert (await async_store.aget(("foo", "langgraph", "foo"), "bar")) is None
|
||||
|
||||
await async_store.abatch([PutOp(("valid", "namespace"), "key", doc)])
|
||||
assert (await async_store.aget(("valid", "namespace"), "key")).value == doc
|
||||
val = await async_store.aget(("valid", "namespace"), "key")
|
||||
assert val is not None
|
||||
assert val.value == doc
|
||||
assert (await async_store.asearch(("valid", "namespace")))[0].value == doc
|
||||
await async_store.adelete(("valid", "namespace"), "key")
|
||||
assert (await async_store.aget(("valid", "namespace"), "key")) is None
|
||||
|
||||
|
||||
async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None:
|
||||
abatch = mocker.spy(InMemoryStore, "batch")
|
||||
store = MockAsyncBatchedStore()
|
||||
|
||||
same_doc = {"value": "same"}
|
||||
diff_doc = {"value": "different"}
|
||||
await asyncio.gather(
|
||||
store.aput(namespace=("test",), key="same", value=same_doc),
|
||||
store.aput(namespace=("test",), key="different", value=diff_doc),
|
||||
)
|
||||
abatch.reset_mock()
|
||||
|
||||
results = await asyncio.gather(
|
||||
store.aget(namespace=("test",), key="same"),
|
||||
store.aget(namespace=("test",), key="same"),
|
||||
store.aget(namespace=("test",), key="different"),
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
assert results[0] == results[1]
|
||||
assert results[0] != results[2]
|
||||
assert results[0].value == same_doc # type: ignore
|
||||
assert results[2].value == diff_doc # type: ignore
|
||||
assert len(abatch.call_args_list) == 1
|
||||
ops = list(abatch.call_args_list[0].args[1])
|
||||
assert len(ops) == 2
|
||||
assert GetOp(("test",), "same") in ops
|
||||
assert GetOp(("test",), "different") in ops
|
||||
|
||||
abatch.reset_mock()
|
||||
|
||||
doc1 = {"value": 1}
|
||||
doc2 = {"value": 2}
|
||||
results = await asyncio.gather(
|
||||
store.aput(namespace=("test",), key="key", value=doc1),
|
||||
store.aput(namespace=("test",), key="key", value=doc2),
|
||||
)
|
||||
assert len(abatch.call_args_list) == 1
|
||||
ops = list(abatch.call_args_list[0].args[1])
|
||||
assert len(ops) == 1
|
||||
assert ops[0] == PutOp(("test",), "key", doc2)
|
||||
assert len(results) == 2
|
||||
assert all(result is None for result in results)
|
||||
|
||||
result = await store.aget(namespace=("test",), key="key")
|
||||
assert result is not None
|
||||
assert result.value == doc2
|
||||
|
||||
abatch.reset_mock()
|
||||
|
||||
results = await asyncio.gather(
|
||||
store.asearch(("test",), filter={"value": 2}),
|
||||
store.asearch(("test",), filter={"value": 2}),
|
||||
)
|
||||
assert len(abatch.call_args_list) == 1
|
||||
ops = list(abatch.call_args_list[0].args[1])
|
||||
assert len(ops) == 1
|
||||
assert len(results) == 2
|
||||
assert results[0] == results[1]
|
||||
assert len(results[0]) == 1
|
||||
assert results[0][0].value == doc2
|
||||
|
||||
abatch.reset_mock()
|
||||
|
||||
+4
-2
@@ -1,11 +1,13 @@
|
||||
.PHONY: test lint format
|
||||
.PHONY: test lint format test-integration
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
test:
|
||||
poetry run pytest tests
|
||||
poetry run pytest tests/unit_tests
|
||||
test-integration:
|
||||
poetry run pytest tests/integration_tests
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
|
||||
+103
-8
@@ -1,10 +1,105 @@
|
||||
# langchain-cli
|
||||
# LangGraph CLI
|
||||
|
||||
This package implements the official CLI for LangGraph API.
|
||||
The official command-line interface for LangGraph, providing tools to create, develop, and deploy LangGraph applications.
|
||||
|
||||
## How to Test CLI Changes Locally
|
||||
These instructions are for CLI development and testing. Use the CLI examples to test CLI changes locally.
|
||||
1. Make changes to the CLI code.
|
||||
1. Navigate to the `libs/cli/examples`: `cd libs/cli/examples`
|
||||
1. Install CLI examples dependencies: `poetry install`
|
||||
1. Run/test CLI command (e.g. `langgraph build`).
|
||||
## Installation
|
||||
|
||||
Install via pip:
|
||||
```bash
|
||||
pip install langgraph-cli
|
||||
```
|
||||
|
||||
For development mode with hot reloading:
|
||||
```bash
|
||||
pip install "langgraph-cli[inmem]"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `langgraph new` 🌱
|
||||
Create a new LangGraph project from a template
|
||||
```bash
|
||||
langgraph new [PATH] --template TEMPLATE_NAME
|
||||
```
|
||||
|
||||
### `langgraph dev` 🏃♀️
|
||||
Run LangGraph API server in development mode with hot reloading
|
||||
```bash
|
||||
langgraph dev [OPTIONS]
|
||||
--host TEXT Host to bind to (default: 127.0.0.1)
|
||||
--port INTEGER Port to bind to (default: 2024)
|
||||
--no-reload Disable auto-reload
|
||||
--debug-port INTEGER Enable remote debugging
|
||||
--no-browser Skip opening browser window
|
||||
-c, --config FILE Config file path (default: langgraph.json)
|
||||
```
|
||||
|
||||
### `langgraph up` 🚀
|
||||
Launch LangGraph API server in Docker
|
||||
```bash
|
||||
langgraph up [OPTIONS]
|
||||
-p, --port INTEGER Port to expose (default: 8123)
|
||||
--wait Wait for services to start
|
||||
--watch Restart on file changes
|
||||
--verbose Show detailed logs
|
||||
-c, --config FILE Config file path
|
||||
-d, --docker-compose Additional services file
|
||||
```
|
||||
|
||||
### `langgraph build`
|
||||
Build a Docker image for your LangGraph application
|
||||
```bash
|
||||
langgraph build -t IMAGE_TAG [OPTIONS]
|
||||
--platform TEXT Target platforms (e.g., linux/amd64,linux/arm64)
|
||||
--pull / --no-pull Use latest/local base image
|
||||
-c, --config FILE Config file path
|
||||
```
|
||||
|
||||
### `langgraph dockerfile`
|
||||
Generate a Dockerfile for custom deployments
|
||||
```bash
|
||||
langgraph dockerfile SAVE_PATH [OPTIONS]
|
||||
-c, --config FILE Config file path
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI uses a `langgraph.json` configuration file with these key settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["langchain_openai", "./your_package"], // Required: Package dependencies
|
||||
"graphs": {
|
||||
"my_graph": "./your_package/file.py:graph" // Required: Graph definitions
|
||||
},
|
||||
"env": "./.env", // Optional: Environment variables
|
||||
"python_version": "3.11", // Optional: Python version (3.11/3.12)
|
||||
"pip_config_file": "./pip.conf", // Optional: pip configuration
|
||||
"dockerfile_lines": [] // Optional: Additional Dockerfile commands
|
||||
}
|
||||
```
|
||||
|
||||
See the [full documentation](https://langchain-ai.github.io/langgraph/docs/cloud/reference/cli.html) for detailed configuration options.
|
||||
|
||||
## Development
|
||||
|
||||
To develop the CLI itself:
|
||||
|
||||
1. Clone the repository
|
||||
2. Navigate to the CLI directory: `cd libs/cli`
|
||||
3. Install development dependencies: `poetry install`
|
||||
4. Make your changes to the CLI code
|
||||
5. Test your changes:
|
||||
```bash
|
||||
# Run CLI commands directly
|
||||
poetry run langgraph --help
|
||||
|
||||
# Or use the examples
|
||||
cd examples
|
||||
poetry install
|
||||
poetry run langgraph dev # or other commands
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms specified in the repository's LICENSE file.
|
||||
|
||||
@@ -1299,9 +1299,9 @@ create-jest@^29.7.0:
|
||||
prompts "^2.0.1"
|
||||
|
||||
cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
||||
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
dependencies:
|
||||
path-key "^3.1.0"
|
||||
shebang-command "^2.0.0"
|
||||
|
||||
+276
-29
@@ -1,4 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
@@ -6,6 +6,7 @@ from typing import Callable, Optional, Sequence
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
from click import secho
|
||||
|
||||
import langgraph_cli.config
|
||||
import langgraph_cli.docker
|
||||
@@ -15,6 +16,8 @@ from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.progress import Progress
|
||||
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
|
||||
from langgraph_cli.version import __version__
|
||||
|
||||
OPT_DOCKER_COMPOSE = click.option(
|
||||
"--docker-compose",
|
||||
@@ -42,7 +45,7 @@ OPT_CONFIG = click.option(
|
||||
- "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where
|
||||
"variable" is an instance of langgraph.graph.graph.CompiledGraph
|
||||
- "env": (optional) path to .env file or a mapping from environment variable to its value
|
||||
- "python_version": (optional) 3.11 or 3.12. Defaults to 3.11
|
||||
- "python_version": (optional) 3.11, 3.12, or 3.13. Defaults to 3.11
|
||||
- "pip_config_file": (optional) path to pip config file
|
||||
- "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image
|
||||
|
||||
@@ -148,6 +151,7 @@ OPT_POSTGRES_URI = click.option(
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__, prog_name="LangGraph CLI")
|
||||
def cli():
|
||||
pass
|
||||
|
||||
@@ -167,9 +171,7 @@ def cli():
|
||||
is_flag=True,
|
||||
help="Wait for services to start before returning. Implies --detach",
|
||||
)
|
||||
@cli.command(
|
||||
help="Start langgraph API server. For local testing, requires a LangSmith API key with access to LangGraph Cloud closed beta. Requires a license key for production use."
|
||||
)
|
||||
@cli.command(help="🚀 Launch LangGraph API server.")
|
||||
@log_command
|
||||
def up(
|
||||
config: pathlib.Path,
|
||||
@@ -188,7 +190,6 @@ def up(
|
||||
click.secho(
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Cloud closed beta.
|
||||
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""",
|
||||
fg="red",
|
||||
)
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
@@ -283,9 +284,11 @@ def _build(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
f"{base_image}:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"{base_image}:{config_json['python_version']}",
|
||||
(
|
||||
f"{base_image}:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"{base_image}:{config_json['python_version']}"
|
||||
),
|
||||
verbose=True,
|
||||
)
|
||||
)
|
||||
@@ -334,7 +337,7 @@ def _build(
|
||||
)
|
||||
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
|
||||
@cli.command(
|
||||
help="Build langgraph API server docker image",
|
||||
help="📦 Build LangGraph API server Docker image.",
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
),
|
||||
@@ -350,30 +353,271 @@ def build(
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
if shutil.which("docker") is None:
|
||||
raise click.UsageError("Docker not installed") from None
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
_build(
|
||||
runner, set, config, config_json, base_image, pull, tag, docker_build_args
|
||||
)
|
||||
|
||||
|
||||
def _get_docker_ignore_content() -> str:
|
||||
"""Return the content of a .dockerignore file.
|
||||
|
||||
This file is used to exclude files and directories from the Docker build context.
|
||||
|
||||
It may be overly broad, but it's better to be safe than sorry.
|
||||
|
||||
The main goal is to exclude .env files by default.
|
||||
"""
|
||||
return """\
|
||||
# Ignore node_modules and other dependency directories
|
||||
node_modules
|
||||
bower_components
|
||||
vendor
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore .env files and other environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Ignore git-related files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Docker-related files and configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore build and cache directories
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
__pycache__
|
||||
|
||||
# Ignore IDE and editor configurations
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store # macOS-specific
|
||||
|
||||
# Ignore test and coverage files
|
||||
coverage
|
||||
*.coverage
|
||||
*.test.js
|
||||
*.spec.js
|
||||
tests
|
||||
"""
|
||||
|
||||
|
||||
@OPT_CONFIG
|
||||
@click.argument("save_path", type=click.Path(resolve_path=True))
|
||||
@cli.command(help="Generate a Dockerfile for langgraph API server")
|
||||
@cli.command(
|
||||
help="🐳 Generate a Dockerfile for the LangGraph API server, with Docker Compose options."
|
||||
)
|
||||
@click.option(
|
||||
# Add a flag for adding a docker-compose.yml file as part of the output
|
||||
"--add-docker-compose",
|
||||
help=(
|
||||
"Add additional files for running the LangGraph API server with "
|
||||
"docker-compose. These files include a docker-compose.yml, .env file, "
|
||||
"and a .dockerignore file."
|
||||
),
|
||||
is_flag=True,
|
||||
)
|
||||
@log_command
|
||||
def dockerfile(save_path: pathlib.Path, config: pathlib.Path):
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
with open(save_path, "w") as f:
|
||||
def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None:
|
||||
save_path = pathlib.Path(save_path).absolute()
|
||||
secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
secho("✅ Configuration validated!", fg="green")
|
||||
|
||||
secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
|
||||
with open(str(save_path), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
langgraph_cli.config.config_to_docker(
|
||||
config,
|
||||
config_json,
|
||||
"langchain/langgraphjs-api"
|
||||
if config_json.get("node_version")
|
||||
else "langchain/langgraph-api",
|
||||
(
|
||||
"langchain/langgraphjs-api"
|
||||
if config_json.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
),
|
||||
)
|
||||
)
|
||||
secho("✅ Created: Dockerfile", fg="green")
|
||||
|
||||
if add_docker_compose:
|
||||
# Add docker compose and related files
|
||||
# Add .dockerignore file in the same directory as the Dockerfile
|
||||
with open(str(save_path.parent / ".dockerignore"), "w", encoding="utf-8") as f:
|
||||
f.write(_get_docker_ignore_content())
|
||||
secho("✅ Created: .dockerignore", fg="green")
|
||||
|
||||
# Generate a docker-compose.yml file
|
||||
path = str(save_path.parent / "docker-compose.yml")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with Runner() as runner:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
|
||||
compose_dict = langgraph_cli.docker.compose_as_dict(
|
||||
capabilities,
|
||||
port=8123,
|
||||
)
|
||||
# Add .env file to the docker-compose.yml for the langgraph-api service
|
||||
compose_dict["services"]["langgraph-api"]["env_file"] = [".env"]
|
||||
# Add the Dockerfile to the build context
|
||||
compose_dict["services"]["langgraph-api"]["build"] = {
|
||||
"context": ".",
|
||||
"dockerfile": save_path.name,
|
||||
}
|
||||
f.write(langgraph_cli.docker.dict_to_yaml(compose_dict))
|
||||
secho("✅ Created: docker-compose.yml", fg="green")
|
||||
|
||||
# Check if the .env file exists in the same directory as the Dockerfile
|
||||
if not (save_path.parent / ".env").exists():
|
||||
# Also add an empty .env file
|
||||
with open(str(save_path.parent / ".env"), "w", encoding="utf-8") as f:
|
||||
f.writelines(
|
||||
[
|
||||
"# Uncomment the following line to add your LangSmith API key",
|
||||
"\n",
|
||||
"# LANGSMITH_API_KEY=your-api-key",
|
||||
"\n",
|
||||
"# Or if you have a LangGraph Cloud license key, "
|
||||
"then uncomment the following line: ",
|
||||
"\n",
|
||||
"# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key",
|
||||
"\n",
|
||||
"# Add any other environment variables go below...",
|
||||
]
|
||||
)
|
||||
|
||||
secho("✅ Created: .env", fg="green")
|
||||
else:
|
||||
# Do nothing since the .env file already exists. Not a great
|
||||
# idea to overwrite in case the user has added custom env vars set
|
||||
# in the .env file already.
|
||||
secho("➖ Skipped: .env. It already exists!", fg="yellow")
|
||||
|
||||
secho(
|
||||
f"🎉 Files generated successfully at path {save_path.parent}!",
|
||||
fg="cyan",
|
||||
bold=True,
|
||||
)
|
||||
|
||||
|
||||
@click.argument("path", required=False)
|
||||
@click.option(
|
||||
"--template",
|
||||
type=str,
|
||||
help=TEMPLATE_HELP_STRING,
|
||||
)
|
||||
@cli.command("new", help="🌱 Create a new LangGraph project from a template.")
|
||||
@log_command
|
||||
def new(path: Optional[str], template: Optional[str]) -> None:
|
||||
"""Create a new LangGraph project from a template."""
|
||||
return create_new(path, template)
|
||||
|
||||
|
||||
@click.option(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Network interface to bind the development server to. Default 127.0.0.1 is recommended for security. Only use 0.0.0.0 in trusted networks",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=2024,
|
||||
type=int,
|
||||
help="Port number to bind the development server to. Example: langgraph dev --port 8000",
|
||||
)
|
||||
@click.option(
|
||||
"--no-reload",
|
||||
is_flag=True,
|
||||
help="Disable automatic reloading when code changes are detected",
|
||||
)
|
||||
@click.option(
|
||||
"--config",
|
||||
type=click.Path(exists=True),
|
||||
default="langgraph.json",
|
||||
help="Path to configuration file declaring dependencies, graphs and environment variables",
|
||||
)
|
||||
@click.option(
|
||||
"--n-jobs-per-worker",
|
||||
default=None,
|
||||
type=int,
|
||||
help="Maximum number of concurrent jobs each worker process can handle. Default: 10",
|
||||
)
|
||||
@click.option(
|
||||
"--no-browser",
|
||||
is_flag=True,
|
||||
help="Skip automatically opening the browser when the server starts",
|
||||
)
|
||||
@click.option(
|
||||
"--debug-port",
|
||||
default=None,
|
||||
type=int,
|
||||
help="Enable remote debugging by listening on specified port. Requires debugpy to be installed",
|
||||
)
|
||||
@cli.command(
|
||||
"dev",
|
||||
help="🏃♀️➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
|
||||
)
|
||||
@log_command
|
||||
def dev(
|
||||
host: str,
|
||||
port: int,
|
||||
no_reload: bool,
|
||||
config: pathlib.Path,
|
||||
n_jobs_per_worker: Optional[int],
|
||||
no_browser: bool,
|
||||
debug_port: Optional[int],
|
||||
):
|
||||
"""CLI entrypoint for running the LangGraph API server."""
|
||||
try:
|
||||
from langgraph_api.cli import run_server
|
||||
except ImportError:
|
||||
try:
|
||||
import pkg_resources
|
||||
|
||||
pkg_resources.require("langgraph-api-inmem")
|
||||
except (ImportError, pkg_resources.DistributionNotFound):
|
||||
raise click.UsageError(
|
||||
"Required package 'langgraph-api-inmem' is not installed.\n"
|
||||
"Please install it with:\n\n"
|
||||
' pip install -U "langgraph-cli[inmem]"\n\n'
|
||||
"If you're developing the langgraph-cli package locally, you can install in development mode:\n"
|
||||
" pip install -e ."
|
||||
) from None
|
||||
raise click.UsageError(
|
||||
"Could not import run_server. This likely means your installation is incomplete.\n"
|
||||
"Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
|
||||
) from None
|
||||
|
||||
config_json = langgraph_cli.config.validate_config_file(config)
|
||||
cwd = os.getcwd()
|
||||
sys.path.append(cwd)
|
||||
dependencies = config_json.get("dependencies", [])
|
||||
for dep in dependencies:
|
||||
dep_path = pathlib.Path(cwd) / dep
|
||||
if dep_path.is_dir() and dep_path.exists():
|
||||
sys.path.append(str(dep_path))
|
||||
|
||||
graphs = config_json.get("graphs", {})
|
||||
run_server(
|
||||
host,
|
||||
port,
|
||||
not no_reload,
|
||||
graphs,
|
||||
n_jobs_per_worker=n_jobs_per_worker,
|
||||
open_browser=not no_browser,
|
||||
debug_port=debug_port,
|
||||
env=config_json.get("env", None),
|
||||
)
|
||||
|
||||
|
||||
def prepare_args_and_stdin(
|
||||
@@ -409,9 +653,11 @@ def prepare_args_and_stdin(
|
||||
config_path,
|
||||
config,
|
||||
watch=watch,
|
||||
base_image="langchain/langgraphjs-api"
|
||||
if config.get("node_version")
|
||||
else "langchain/langgraph-api",
|
||||
base_image=(
|
||||
"langchain/langgraphjs-api"
|
||||
if config.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
),
|
||||
)
|
||||
return args, stdin
|
||||
|
||||
@@ -430,17 +676,18 @@ def prepare(
|
||||
debugger_base_url: Optional[str] = None,
|
||||
postgres_uri: Optional[str] = None,
|
||||
):
|
||||
with open(config_path) as f:
|
||||
config = langgraph_cli.config.validate_config(json.load(f))
|
||||
config_json = langgraph_cli.config.validate_config_file(config_path)
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
f"langchain/langgraphjs-api:{config['node_version']}"
|
||||
if config.get("node_version")
|
||||
else f"langchain/langgraph-api:{config['python_version']}",
|
||||
(
|
||||
f"langchain/langgraphjs-api:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"langchain/langgraph-api:{config_json['python_version']}"
|
||||
),
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
@@ -448,7 +695,7 @@ def prepare(
|
||||
args, stdin = prepare_args_and_stdin(
|
||||
capabilities=capabilities,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
config=config_json,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
watch=watch,
|
||||
|
||||
@@ -6,6 +6,9 @@ from typing import NamedTuple, Optional, TypedDict, Union
|
||||
|
||||
import click
|
||||
|
||||
MIN_NODE_VERSION = "20"
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
python_version: str
|
||||
@@ -17,6 +20,28 @@ class Config(TypedDict):
|
||||
env: Union[dict[str, str], str]
|
||||
|
||||
|
||||
def _parse_version(version_str: str) -> tuple[int, int]:
|
||||
"""Parse a version string into a tuple of (major, minor)."""
|
||||
try:
|
||||
major, minor = map(int, version_str.split("."))
|
||||
return (major, minor)
|
||||
except ValueError:
|
||||
raise click.UsageError(f"Invalid version format: {version_str}") from None
|
||||
|
||||
|
||||
def _parse_node_version(version_str: str) -> int:
|
||||
"""Parse a Node.js version string into a major version number."""
|
||||
try:
|
||||
if "." in version_str:
|
||||
raise ValueError("Node.js version must be major version only")
|
||||
return int(version_str)
|
||||
except ValueError:
|
||||
raise click.UsageError(
|
||||
f"Invalid Node.js version format: {version_str}. "
|
||||
"Use major version only (e.g., '20')."
|
||||
) from None
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
config = (
|
||||
{
|
||||
@@ -37,21 +62,34 @@ def validate_config(config: Config) -> Config:
|
||||
)
|
||||
|
||||
if config.get("node_version"):
|
||||
if config["node_version"] not in ("20",):
|
||||
raise click.UsageError(
|
||||
f"Unsupported Node.js version: {config['node_version']}. "
|
||||
"Currently only `node_version: \"20\"` is supported."
|
||||
)
|
||||
node_version = config["node_version"]
|
||||
try:
|
||||
major = _parse_node_version(node_version)
|
||||
min_major = _parse_node_version(MIN_NODE_VERSION)
|
||||
if major < min_major:
|
||||
raise click.UsageError(
|
||||
f"Node.js version {node_version} is not supported. "
|
||||
f"Minimum required version is {MIN_NODE_VERSION}."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
if config.get("python_version"):
|
||||
if config["python_version"] not in (
|
||||
"3.11",
|
||||
"3.12",
|
||||
pyversion = config["python_version"]
|
||||
if not pyversion.count(".") == 1 or not all(
|
||||
part.isdigit() for part in pyversion.split(".")
|
||||
):
|
||||
raise click.UsageError(
|
||||
f"Unsupported Python version: {config['python_version']}. "
|
||||
"Supported versions are 3.11 and 3.12."
|
||||
f"Invalid Python version format: {pyversion}. "
|
||||
"Use 'major.minor' format (e.g., '3.11'). "
|
||||
"Patch version cannot be specified."
|
||||
)
|
||||
if _parse_version(pyversion) < _parse_version(MIN_PYTHON_VERSION):
|
||||
raise click.UsageError(
|
||||
f"Python version {pyversion} is not supported. "
|
||||
f"Minimum required version is {MIN_PYTHON_VERSION}."
|
||||
)
|
||||
|
||||
if not config["dependencies"]:
|
||||
raise click.UsageError(
|
||||
"No dependencies found in config. "
|
||||
@@ -66,6 +104,48 @@ def validate_config(config: Config) -> Config:
|
||||
return config
|
||||
|
||||
|
||||
def validate_config_file(config_path: pathlib.Path) -> Config:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
validated = validate_config(config)
|
||||
# Enforce the package.json doesn't enforce an
|
||||
# incompatible Node.js version
|
||||
if validated.get("node_version"):
|
||||
package_json_path = config_path.parent / "package.json"
|
||||
if package_json_path.is_file():
|
||||
try:
|
||||
with open(package_json_path) as f:
|
||||
package_json = json.load(f)
|
||||
if "engines" in package_json:
|
||||
engines = package_json["engines"]
|
||||
if any(engine != "node" for engine in engines.keys()):
|
||||
raise click.UsageError(
|
||||
"Only 'node' engine is supported in package.json engines."
|
||||
f" Got engines: {list(engines.keys())}"
|
||||
)
|
||||
if engines:
|
||||
node_version = engines["node"]
|
||||
try:
|
||||
major = _parse_node_version(node_version)
|
||||
min_major = _parse_node_version(MIN_NODE_VERSION)
|
||||
if major < min_major:
|
||||
raise click.UsageError(
|
||||
f"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} "
|
||||
f"(major version only), got '{node_version}'. Minor/patch versions "
|
||||
"(like '20.x.y') are not supported to prevent deployment issues "
|
||||
"when new Node.js versions are released."
|
||||
)
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
except json.JSONDecodeError:
|
||||
raise click.UsageError(
|
||||
"Invalid package.json found in langgraph "
|
||||
f"config directory {package_json_path}: file is not valid JSON"
|
||||
) from None
|
||||
return validated
|
||||
|
||||
|
||||
class LocalDeps(NamedTuple):
|
||||
pip_reqs: list[tuple[pathlib.Path, str]]
|
||||
real_pkgs: dict[pathlib.Path, str]
|
||||
|
||||
@@ -12,34 +12,6 @@ DEFAULT_POSTGRES_URI = (
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
||||
)
|
||||
|
||||
REDIS = """
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
"""
|
||||
|
||||
DB = """
|
||||
langgraph-postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
"""
|
||||
|
||||
|
||||
class Version(NamedTuple):
|
||||
major: int
|
||||
@@ -116,28 +88,149 @@ def check_capabilities(runner) -> DockerCapabilities:
|
||||
|
||||
def debugger_compose(
|
||||
*, port: Optional[int] = None, base_url: Optional[str] = None
|
||||
) -> str:
|
||||
) -> dict:
|
||||
if port is None:
|
||||
return ""
|
||||
|
||||
compose_str = """
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{port}:3968"
|
||||
"""
|
||||
config = {
|
||||
"langgraph-debugger": {
|
||||
"image": "langchain/langgraph-debugger",
|
||||
"restart": "on-failure",
|
||||
"depends_on": {
|
||||
"langgraph-postgres": {"condition": "service_healthy"},
|
||||
},
|
||||
"ports": [f'"{port}:3968"'],
|
||||
}
|
||||
}
|
||||
|
||||
if base_url:
|
||||
compose_str += """
|
||||
environment:
|
||||
VITE_STUDIO_LOCAL_GRAPH_URL: {base_url}
|
||||
"""
|
||||
config["langgraph-debugger"]["environment"] = {
|
||||
"VITE_STUDIO_LOCAL_GRAPH_URL": base_url
|
||||
}
|
||||
|
||||
return compose_str.format(port=port, base_url=base_url)
|
||||
return config
|
||||
|
||||
|
||||
# Function to convert dictionary to YAML
|
||||
def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
|
||||
"""Convert a dictionary to a YAML string."""
|
||||
yaml_str = ""
|
||||
|
||||
for idx, (key, value) in enumerate(d.items()):
|
||||
# Format things in a visually appealing way
|
||||
# Use an extra newline for top-level keys only
|
||||
if idx >= 1 and indent < 2:
|
||||
yaml_str += "\n"
|
||||
space = " " * indent
|
||||
if isinstance(value, dict):
|
||||
yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1)
|
||||
elif isinstance(value, list):
|
||||
yaml_str += f"{space}{key}:\n"
|
||||
for item in value:
|
||||
yaml_str += f"{space} - {item}\n"
|
||||
else:
|
||||
yaml_str += f"{space}{key}: {value}\n"
|
||||
return yaml_str
|
||||
|
||||
|
||||
def compose_as_dict(
|
||||
capabilities: DockerCapabilities,
|
||||
*,
|
||||
port: int,
|
||||
debugger_port: Optional[int] = None,
|
||||
debugger_base_url: Optional[str] = None,
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Create a docker compose file as a dictionary in YML style."""
|
||||
if postgres_uri is None:
|
||||
include_db = True
|
||||
postgres_uri = DEFAULT_POSTGRES_URI
|
||||
else:
|
||||
include_db = False
|
||||
|
||||
# The services below are defined in a non-intuitive order to match
|
||||
# the existing unit tests for this function.
|
||||
# It's fine to re-order just requires updating the unit tests, so it should
|
||||
# be done with caution.
|
||||
|
||||
# Define the Redis service first as per the test order
|
||||
services = {
|
||||
"langgraph-redis": {
|
||||
"image": "redis:6",
|
||||
"healthcheck": {
|
||||
"test": "redis-cli ping",
|
||||
"interval": "5s",
|
||||
"timeout": "1s",
|
||||
"retries": 5,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Add Postgres service before langgraph-api if it is needed
|
||||
if include_db:
|
||||
services["langgraph-postgres"] = {
|
||||
"image": "postgres:16",
|
||||
"ports": ['"5433:5432"'],
|
||||
"environment": {
|
||||
"POSTGRES_DB": "postgres",
|
||||
"POSTGRES_USER": "postgres",
|
||||
"POSTGRES_PASSWORD": "postgres",
|
||||
},
|
||||
"volumes": ["langgraph-data:/var/lib/postgresql/data"],
|
||||
"healthcheck": {
|
||||
"test": "pg_isready -U postgres",
|
||||
"start_period": "10s",
|
||||
"timeout": "1s",
|
||||
"retries": 5,
|
||||
},
|
||||
}
|
||||
if capabilities.healthcheck_start_interval:
|
||||
services["langgraph-postgres"]["healthcheck"]["interval"] = "60s"
|
||||
services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s"
|
||||
else:
|
||||
services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"
|
||||
|
||||
# Add optional debugger service if debugger_port is specified
|
||||
if debugger_port:
|
||||
services["langgraph-debugger"] = debugger_compose(
|
||||
port=debugger_port, base_url=debugger_base_url
|
||||
)["langgraph-debugger"]
|
||||
|
||||
# Add langgraph-api service
|
||||
services["langgraph-api"] = {
|
||||
"ports": [f'"{port}:8000"'],
|
||||
"depends_on": {
|
||||
"langgraph-redis": {"condition": "service_healthy"},
|
||||
},
|
||||
"environment": {
|
||||
"REDIS_URI": "redis://langgraph-redis:6379",
|
||||
"POSTGRES_URI": postgres_uri,
|
||||
},
|
||||
}
|
||||
|
||||
# If Postgres is included, add it to the dependencies of langgraph-api
|
||||
if include_db:
|
||||
services["langgraph-api"]["depends_on"]["langgraph-postgres"] = {
|
||||
"condition": "service_healthy"
|
||||
}
|
||||
|
||||
# Additional healthcheck for langgraph-api if required
|
||||
if capabilities.healthcheck_start_interval:
|
||||
services["langgraph-api"]["healthcheck"] = {
|
||||
"test": "python /api/healthcheck.py",
|
||||
"interval": "60s",
|
||||
"start_interval": "1s",
|
||||
"start_period": "10s",
|
||||
}
|
||||
|
||||
# Final compose dictionary with volumes included if needed
|
||||
compose_dict = {}
|
||||
if include_db:
|
||||
compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}}
|
||||
compose_dict["services"] = services
|
||||
|
||||
return compose_dict
|
||||
|
||||
|
||||
def compose(
|
||||
@@ -149,54 +242,13 @@ def compose(
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> str:
|
||||
if postgres_uri is None:
|
||||
include_db = True
|
||||
postgres_uri = DEFAULT_POSTGRES_URI
|
||||
else:
|
||||
include_db = False
|
||||
|
||||
db = DB.format() if include_db else ""
|
||||
volumes = (
|
||||
"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
"""
|
||||
if include_db
|
||||
else ""
|
||||
"""Create a docker compose file as a string."""
|
||||
compose_content = compose_as_dict(
|
||||
capabilities,
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_base_url,
|
||||
postgres_uri=postgres_uri,
|
||||
)
|
||||
if db:
|
||||
if capabilities.healthcheck_start_interval:
|
||||
db += """
|
||||
interval: 60s
|
||||
start_interval: 1s"""
|
||||
else:
|
||||
db += """
|
||||
interval: 5s"""
|
||||
|
||||
compose_str = f"""{volumes}services:
|
||||
{REDIS}
|
||||
{db}
|
||||
{debugger_compose(port=debugger_port, base_url=debugger_base_url)}
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "{port}:8000\"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy"""
|
||||
if include_db:
|
||||
compose_str += """
|
||||
langgraph-postgres:
|
||||
condition: service_healthy"""
|
||||
compose_str += f"""
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {postgres_uri}
|
||||
"""
|
||||
if capabilities.healthcheck_start_interval:
|
||||
compose_str += """ healthcheck:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s"""
|
||||
|
||||
compose_str = dict_to_yaml(compose_content)
|
||||
return compose_str
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional
|
||||
from urllib import error, request
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
|
||||
TEMPLATES: Dict[str, Dict[str, str]] = {
|
||||
"New LangGraph Project": {
|
||||
"description": "A simple, minimal chatbot with memory.",
|
||||
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
"ReAct Agent": {
|
||||
"description": "A simple agent that can be flexibly extended to many tools.",
|
||||
"python": "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Memory Agent": {
|
||||
"description": "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
"python": "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Retrieval Agent": {
|
||||
"description": "An agent that includes a retrieval-based question-answering system.",
|
||||
"python": "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Data-enrichment Agent": {
|
||||
"description": "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
"python": "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
}
|
||||
|
||||
# Generate TEMPLATE_IDS programmatically
|
||||
TEMPLATE_ID_TO_CONFIG = {
|
||||
f"{name.lower().replace(' ', '-')}-{lang}": (name, lang, url)
|
||||
for name, versions in TEMPLATES.items()
|
||||
for lang, url in versions.items()
|
||||
if lang in {"python", "js"}
|
||||
}
|
||||
|
||||
TEMPLATE_IDS = list(TEMPLATE_ID_TO_CONFIG.keys())
|
||||
|
||||
TEMPLATE_HELP_STRING = (
|
||||
"The name of the template to use. Available options:\n"
|
||||
+ "\n".join(f"{id_}" for id_ in TEMPLATE_ID_TO_CONFIG)
|
||||
)
|
||||
|
||||
|
||||
def _choose_template() -> str:
|
||||
"""Presents a list of templates to the user and prompts them to select one.
|
||||
|
||||
Returns:
|
||||
str: The URL of the selected template.
|
||||
"""
|
||||
click.secho("🌟 Please select a template:", bold=True, fg="yellow")
|
||||
for idx, (template_name, template_info) in enumerate(TEMPLATES.items(), 1):
|
||||
click.secho(f"{idx}. ", nl=False, fg="cyan")
|
||||
click.secho(template_name, fg="cyan", nl=False)
|
||||
click.secho(f" - {template_info['description']}", fg="white")
|
||||
|
||||
# Get the template choice from the user, defaulting to the first template if blank
|
||||
template_choice: Optional[int] = click.prompt(
|
||||
"Enter the number of your template choice (default is 1)",
|
||||
type=int,
|
||||
default=1,
|
||||
show_default=False,
|
||||
)
|
||||
|
||||
template_keys = list(TEMPLATES.keys())
|
||||
if 1 <= template_choice <= len(template_keys):
|
||||
selected_template: str = template_keys[template_choice - 1]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
# Prompt the user to choose between Python or JS/TS version
|
||||
click.secho(
|
||||
f"\nYou selected: {selected_template} - {TEMPLATES[selected_template]['description']}",
|
||||
fg="green",
|
||||
)
|
||||
version_choice: int = click.prompt(
|
||||
"Choose language (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[selected_template]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[selected_template]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
|
||||
def _download_repo_with_requests(repo_url: str, path: str) -> None:
|
||||
"""Download a ZIP archive from the given URL and extracts it to the specified path.
|
||||
|
||||
Args:
|
||||
repo_url (str): The URL of the repository to download.
|
||||
path (str): The path where the repository should be extracted.
|
||||
"""
|
||||
click.secho("📥 Attempting to download repository as a ZIP archive...", fg="yellow")
|
||||
click.secho(f"URL: {repo_url}", fg="yellow")
|
||||
try:
|
||||
with request.urlopen(repo_url) as response:
|
||||
if response.status == 200:
|
||||
with ZipFile(BytesIO(response.read())) as zip_file:
|
||||
zip_file.extractall(path)
|
||||
# Move extracted contents to path
|
||||
for item in os.listdir(path):
|
||||
if item.endswith("-main"):
|
||||
extracted_dir = os.path.join(path, item)
|
||||
for filename in os.listdir(extracted_dir):
|
||||
shutil.move(os.path.join(extracted_dir, filename), path)
|
||||
shutil.rmtree(extracted_dir)
|
||||
click.secho(
|
||||
f"✅ Downloaded and extracted repository to {path}", fg="green"
|
||||
)
|
||||
except error.HTTPError as e:
|
||||
click.secho(
|
||||
f"❌ Error: Failed to download repository.\n" f"Details: {e}\n",
|
||||
fg="red",
|
||||
bold=True,
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _get_template_url(template_name: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieves the template URL based on the provided template name.
|
||||
|
||||
Args:
|
||||
template_name (str): The name of the template.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The URL of the template if found, else None.
|
||||
"""
|
||||
if template_name in TEMPLATES:
|
||||
click.secho(f"Template selected: {template_name}", fg="green")
|
||||
version_choice: int = click.prompt(
|
||||
"Choose version (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[template_name]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[template_name]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return None
|
||||
else:
|
||||
click.secho(
|
||||
f"Template '{template_name}' not found. Please select from the available options.",
|
||||
fg="red",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def create_new(path: Optional[str], template: Optional[str]) -> None:
|
||||
"""Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
|
||||
|
||||
Args:
|
||||
path (Optional[str]): The path where the new project will be created.
|
||||
template (Optional[str]): The name of the template to use.
|
||||
"""
|
||||
# Prompt for path if not provided
|
||||
if not path:
|
||||
path = click.prompt(
|
||||
"📂 Please specify the path to create the application", default="."
|
||||
)
|
||||
|
||||
path = os.path.abspath(path) # Ensure path is absolute
|
||||
|
||||
# Check if path exists and is not empty
|
||||
if os.path.exists(path) and os.listdir(path):
|
||||
click.secho(
|
||||
"❌ The specified directory already exists and is not empty. "
|
||||
"Aborting to prevent overwriting files.",
|
||||
fg="red",
|
||||
bold=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Get template URL either from command-line argument or
|
||||
# through interactive selection
|
||||
if template:
|
||||
if template not in TEMPLATE_ID_TO_CONFIG:
|
||||
# Format available options in a readable way with descriptions
|
||||
template_options = ""
|
||||
for id_ in TEMPLATE_IDS:
|
||||
name, lang, _ = TEMPLATE_ID_TO_CONFIG[id_]
|
||||
description = TEMPLATES[name]["description"]
|
||||
|
||||
# Add each template option with color formatting
|
||||
template_options += (
|
||||
click.style("- ", fg="yellow", bold=True)
|
||||
+ click.style(f"{id_}", fg="cyan")
|
||||
+ click.style(f": {description}", fg="white")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Display error message with colors and formatting
|
||||
click.secho("❌ Error:", fg="red", bold=True, nl=False)
|
||||
click.secho(f" Template '{template}' not found.", fg="red")
|
||||
click.secho(
|
||||
"Please select from the available options:\n", fg="yellow", bold=True
|
||||
)
|
||||
click.secho(template_options, fg="cyan")
|
||||
sys.exit(1)
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template]
|
||||
else:
|
||||
template_url = _choose_template()
|
||||
|
||||
# Download and extract the template
|
||||
_download_repo_with_requests(template_url, path)
|
||||
|
||||
click.secho(f"🎉 New project created at {path}", fg="green", bold=True)
|
||||
Generated
+1295
-111
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.53"
|
||||
version = "0.1.59"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -14,6 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
langgraph-api = { version = ">=0.0.2,<0.1.0", optional = true , python=">=3.11,<4.0" }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.6.2"
|
||||
@@ -24,6 +25,9 @@ pytest-mock = "^3.11.1"
|
||||
pytest-watch = "^4.2.0"
|
||||
mypy = "^1.10.0"
|
||||
|
||||
[tool.poetry.extras]
|
||||
inmem = ["langgraph-api"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# --strict-markers will raise errors on unknown marks.
|
||||
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_key", TEMPLATE_ID_TO_CONFIG.keys())
|
||||
def test_template_urls_work(template_key: str) -> None:
|
||||
"""Integration test to verify that all template URLs are reachable."""
|
||||
_, _, template_url = TEMPLATE_ID_TO_CONFIG[template_key]
|
||||
response = requests.head(template_url)
|
||||
# Returns 302 on a successful HEAD request
|
||||
assert response.status_code == 302, f"URL {template_url} is not reachable."
|
||||
@@ -0,0 +1,244 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
version_docker=Version(26, 1, 1),
|
||||
version_compose=Version(2, 27, 0),
|
||||
healthcheck_start_interval=True,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_config_folder(config_content: dict):
|
||||
# Create a temporary directory
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
# Define the path for the config.json file
|
||||
config_path = Path(temp_dir) / "config.json"
|
||||
|
||||
# Write the provided dictionary content to config.json
|
||||
with open(config_path, "w", encoding="utf-8") as config_file:
|
||||
json.dump(config_content, config_file)
|
||||
|
||||
# Yield the temporary directory path for use within the context
|
||||
yield config_path.parent
|
||||
finally:
|
||||
# Cleanup the temporary directory and its contents
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def test_prepare_args_and_stdin() -> None:
|
||||
# this basically serves as an end-to-end test for using config and docker helpers
|
||||
config_path = pathlib.Path("./langgraph.json")
|
||||
config = validate_config(
|
||||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
debugger_port = 8001
|
||||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose=pathlib.Path("custom-docker-compose.yml"),
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_graph_url,
|
||||
watch=True,
|
||||
)
|
||||
|
||||
expected_args = [
|
||||
"--project-directory",
|
||||
".",
|
||||
"-f",
|
||||
"custom-docker-compose.yml",
|
||||
"-f",
|
||||
"-",
|
||||
]
|
||||
expected_stdin = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{debugger_port}:3968"
|
||||
environment:
|
||||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
|
||||
healthcheck:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||||
WORKDIR /deps/
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: langgraph.json
|
||||
action: rebuild
|
||||
- path: .
|
||||
action: rebuild\
|
||||
"""
|
||||
assert actual_args == expected_args
|
||||
assert clean_empty_lines(actual_stdin) == expected_stdin
|
||||
|
||||
|
||||
def test_version_option() -> None:
|
||||
"""Test the --version option of the CLI."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
|
||||
# Verify that the command executed successfully
|
||||
assert result.exit_code == 0, "Expected exit code 0 for --version option"
|
||||
|
||||
# Check that the output contains the correct version information
|
||||
assert (
|
||||
"LangGraph CLI, version" in result.output
|
||||
), "Expected version information in output"
|
||||
|
||||
|
||||
def test_dockerfile_command_basic() -> None:
|
||||
"""Test the 'dockerfile' command with basic configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"node_version": "20", # Add any other necessary configuration fields
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["dockerfile", str(save_path), "--config", str(temp_dir / "config.json")],
|
||||
)
|
||||
|
||||
# Assert command was successful
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "✅ Created: Dockerfile" in result.output
|
||||
|
||||
# Check if Dockerfile was created
|
||||
assert save_path.exists()
|
||||
|
||||
|
||||
def test_dockerfile_command_with_docker_compose() -> None:
|
||||
"""Test the 'dockerfile' command with Docker Compose configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"dependencies": ["./my_agent"],
|
||||
"graphs": {"agent": "./my_agent/agent.py:graph"},
|
||||
"env": ".env",
|
||||
}
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
# Add agent.py file
|
||||
agent_path = temp_dir / "my_agent" / "agent.py"
|
||||
agent_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
agent_path.touch()
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"dockerfile",
|
||||
str(save_path),
|
||||
"--config",
|
||||
str(temp_dir / "config.json"),
|
||||
"--add-docker-compose",
|
||||
],
|
||||
)
|
||||
|
||||
# Assert command was successful
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Created: Dockerfile" in result.output
|
||||
assert "✅ Created: .dockerignore" in result.output
|
||||
assert "✅ Created: docker-compose.yml" in result.output
|
||||
assert (
|
||||
"✅ Created: .env" in result.output or "➖ Skipped: .env" in result.output
|
||||
)
|
||||
assert "🎉 Files generated successfully" in result.output
|
||||
|
||||
# Check if Dockerfile, .dockerignore, docker-compose.yml, and .env were created
|
||||
assert save_path.exists()
|
||||
assert (temp_dir / ".dockerignore").exists()
|
||||
assert (temp_dir / "docker-compose.yml").exists()
|
||||
assert (temp_dir / ".env").exists() or "➖ Skipped: .env" in result.output
|
||||
|
||||
|
||||
def test_dockerfile_command_with_bad_config() -> None:
|
||||
"""Test the 'dockerfile' command with basic configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"node_version": "20" # Add any other necessary configuration fields
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
save_path = temp_dir / "Dockerfile"
|
||||
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["dockerfile", str(save_path), "--config", str(temp_dir / "conf.json")],
|
||||
)
|
||||
|
||||
# Assert command was successful
|
||||
assert result.exit_code == 2
|
||||
assert "conf.json' does not exist" in result.output
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for the 'new' CLI command.
|
||||
|
||||
This command creates a new LangGraph project using a specified template.
|
||||
"""
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib import request
|
||||
from zipfile import ZipFile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from langgraph_cli.cli import cli
|
||||
from langgraph_cli.templates import TEMPLATE_ID_TO_CONFIG
|
||||
|
||||
|
||||
@patch.object(request, "urlopen")
|
||||
def test_create_new_with_mocked_download(mock_urlopen: MagicMock) -> None:
|
||||
"""Test the 'new' CLI command with a mocked download response using urllib."""
|
||||
# Mock the response content to simulate a ZIP file
|
||||
mock_zip_content = BytesIO()
|
||||
with ZipFile(mock_zip_content, "w") as mock_zip:
|
||||
mock_zip.writestr("test-file.txt", "Test content.")
|
||||
|
||||
# Create a mock response that behaves like a context manager
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_zip_content.getvalue()
|
||||
mock_response.__enter__.return_value = mock_response # Setup enter context
|
||||
mock_response.status = 200
|
||||
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
runner = CliRunner()
|
||||
template = next(
|
||||
iter(TEMPLATE_ID_TO_CONFIG)
|
||||
) # Select the first template for the test
|
||||
result = runner.invoke(cli, ["new", temp_dir, "--template", template])
|
||||
|
||||
# Verify CLI command execution and success
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
"New project created" in result.output
|
||||
), "Expected success message in output."
|
||||
|
||||
# Verify that the directory is not empty
|
||||
assert os.listdir(temp_dir), "Expected files to be created in temp directory."
|
||||
|
||||
# Check for a known file in the extracted content
|
||||
extracted_files = [f.name for f in Path(temp_dir).glob("*")]
|
||||
assert (
|
||||
"test-file.txt" in extracted_files
|
||||
), "Expected 'test-file.txt' in the extracted content."
|
||||
|
||||
|
||||
def test_invalid_template_id() -> None:
|
||||
"""Test that an invalid template ID passed via CLI results in a graceful error."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["new", "dummy_path", "--template", "invalid-template-id"]
|
||||
)
|
||||
|
||||
# Verify the command failed and proper message is displayed
|
||||
assert result.exit_code != 0, "Expected non-zero exit code for invalid template."
|
||||
assert (
|
||||
"Template 'invalid-template-id' not found" in result.output
|
||||
), "Expected error message in output."
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_analytics_env() -> None:
|
||||
"""Disable analytics for unit tests LANGGRAPH_CLI_NO_ANALYTICS."""
|
||||
# First check if the environment variable is already set, if so, log a warning prior
|
||||
# to overriding it.
|
||||
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
|
||||
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
|
||||
|
||||
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
|
||||
yield
|
||||
@@ -1,117 +0,0 @@
|
||||
import pathlib
|
||||
|
||||
from langgraph_cli.cli import prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
version_docker=Version(26, 1, 1),
|
||||
version_compose=Version(2, 27, 0),
|
||||
healthcheck_start_interval=True,
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_args_and_stdin():
|
||||
# this basically serves as an end-to-end test for using config and docker helpers
|
||||
config_path = pathlib.Path("./langgraph.json")
|
||||
config = validate_config(
|
||||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
debugger_port = 8001
|
||||
debugger_graph_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose="custom-docker-compose.yml",
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
debugger_base_url=debugger_graph_url,
|
||||
watch=True,
|
||||
)
|
||||
|
||||
expected_args = [
|
||||
"--project-directory",
|
||||
".",
|
||||
"-f",
|
||||
"custom-docker-compose.yml",
|
||||
"-f",
|
||||
"-",
|
||||
]
|
||||
expected_stdin = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-redis:
|
||||
image: redis:6
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
langgraph-postgres:
|
||||
image: postgres:16
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "{debugger_port}:3968"
|
||||
environment:
|
||||
VITE_STUDIO_LOCAL_GRAPH_URL: {debugger_graph_url}
|
||||
langgraph-api:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
langgraph-redis:
|
||||
condition: service_healthy
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
REDIS_URI: redis://langgraph-redis:6379
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
|
||||
healthcheck:
|
||||
test: python /api/healthcheck.py
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||||
WORKDIR /deps/
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: langgraph.json
|
||||
action: rebuild
|
||||
- path: .
|
||||
action: rebuild\
|
||||
"""
|
||||
assert actual_args == expected_args
|
||||
assert clean_empty_lines(actual_stdin) == expected_stdin
|
||||
@@ -1,10 +1,17 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.config import config_to_compose, config_to_docker, validate_config
|
||||
from langgraph_cli.config import (
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
validate_config,
|
||||
validate_config_file,
|
||||
)
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"
|
||||
@@ -42,6 +49,9 @@ def test_validate_config():
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
expected_config["python_version"] = "3.13"
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
|
||||
# check wrong python version raises
|
||||
with pytest.raises(click.UsageError):
|
||||
@@ -61,6 +71,86 @@ def test_validate_config():
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config({"python_version": "3.9", "dependencies": ["."]})
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "3.11.0"})
|
||||
assert "Invalid Python version format" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "3"})
|
||||
assert "Invalid Python version format" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "abc.def"})
|
||||
assert "Invalid Python version format" in str(exc_info.value)
|
||||
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
validate_config({"python_version": "3.10"})
|
||||
assert "Minimum required version" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_validate_config_file():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = pathlib.Path(tmpdir)
|
||||
|
||||
config_path = tmpdir_path / "langgraph.json"
|
||||
|
||||
node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(node_config, f)
|
||||
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json = {"name": "test", "engines": {"node": "20"}}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"]["node"] = "20.18"
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="Use major version only"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"] = {"node": "18"}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="must be >= 20"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
package_json["engines"] = {"node": "20", "deno": "1.0"}
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
json.dump(package_json, f)
|
||||
with pytest.raises(click.UsageError, match="Only 'node' engine is supported"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
f.write("{invalid json")
|
||||
with pytest.raises(click.UsageError, match="Invalid package.json"):
|
||||
validate_config_file(config_path)
|
||||
|
||||
python_config = {
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
}
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(python_config, f)
|
||||
|
||||
validate_config_file(config_path)
|
||||
|
||||
for package_content in [
|
||||
{"name": "test"},
|
||||
{"engines": {"node": "18"}},
|
||||
{"engines": {"node": "20", "deno": "1.0"}},
|
||||
"{invalid json",
|
||||
]:
|
||||
with open(tmpdir_path / "package.json", "w") as f:
|
||||
if isinstance(package_content, dict):
|
||||
json.dump(package_content, f)
|
||||
else:
|
||||
f.write(package_content)
|
||||
validate_config_file(config_path)
|
||||
|
||||
|
||||
# config_to_docker
|
||||
def test_config_to_docker_simple():
|
||||
|
||||
@@ -48,8 +48,13 @@ test:
|
||||
make stop-postgres; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
WORKERS ?= auto
|
||||
XDIST_ARGS := $(if $(WORKERS),-n $(WORKERS) --dist worksteal,)
|
||||
MAXFAIL ?=
|
||||
MAXFAIL_ARGS := $(if $(MAXFAIL),--maxfail $(MAXFAIL),)
|
||||
|
||||
test_watch:
|
||||
make start-postgres && poetry run ptw . -- --ff -v -x -n auto --dist worksteal --snapshot-update --tb short $(TEST); \
|
||||
make start-postgres && poetry run ptw . -- --ff -vv -x $(XDIST_ARGS) $(MAXFAIL_ARGS) --snapshot-update --tb short $(TEST); \
|
||||
EXIT_CODE=$$?; \
|
||||
make stop-postgres; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
@@ -238,7 +238,7 @@ final_state["messages"][-1].content
|
||||
* [How-to Guides](https://langchain-ai.github.io/langgraph/how-tos/): Accomplish specific things within LangGraph, from streaming, to adding memory & persistence, to common design patterns (branching, subgraphs, etc.), these are the place to go if you want to copy and run a specific code snippet.
|
||||
* [Conceptual Guides](https://langchain-ai.github.io/langgraph/concepts/high_level/): In-depth explanations of the key concepts and principles behind LangGraph, such as nodes, edges, state and more.
|
||||
* [API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/): Review important classes and methods, simple examples of how to use the graph and checkpointing APIs, higher-level prebuilt components and more.
|
||||
* [Cloud (beta)](https://langchain-ai.github.io/langgraph/cloud/): With one click, deploy LangGraph applications to LangGraph Cloud.
|
||||
* [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform): LangGraph Platform is a commercial solution for deploying agentic applications in production, built on the open-source LangGraph framework.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sys
|
||||
from os import getenv
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Literal, Mapping, cast
|
||||
|
||||
@@ -10,6 +11,7 @@ from langgraph.types import Interrupt, Send # noqa: F401
|
||||
# --- Empty read-only containers ---
|
||||
EMPTY_MAP: Mapping[str, Any] = MappingProxyType({})
|
||||
EMPTY_SEQ: tuple[str, ...] = tuple()
|
||||
MISSING = object()
|
||||
|
||||
# --- Public constants ---
|
||||
TAG_NOSTREAM = sys.intern("langsmith:nostream")
|
||||
@@ -20,12 +22,16 @@ START = sys.intern("__start__")
|
||||
"""The first (maybe virtual) node in graph-style Pregel."""
|
||||
END = sys.intern("__end__")
|
||||
"""The last (maybe virtual) node in graph-style Pregel."""
|
||||
SELF = sys.intern("__self__")
|
||||
"""The implicit branch that handles each node's Control values."""
|
||||
|
||||
# --- Reserved write keys ---
|
||||
INPUT = sys.intern("__input__")
|
||||
# for values passed as input to the graph
|
||||
INTERRUPT = sys.intern("__interrupt__")
|
||||
# for dynamic interrupts raised by nodes
|
||||
RESUME = sys.intern("__resume__")
|
||||
# for values passed to resume a node after an interrupt
|
||||
ERROR = sys.intern("__error__")
|
||||
# for errors raised by nodes
|
||||
NO_WRITES = sys.intern("__no_writes__")
|
||||
@@ -67,6 +73,8 @@ CONFIG_KEY_CHECKPOINT_NS = sys.intern("checkpoint_ns")
|
||||
# holds the current checkpoint_ns, "" for root graph
|
||||
CONFIG_KEY_NODE_FINISHED = sys.intern("__pregel_node_finished")
|
||||
# callback to be called when a node is finished
|
||||
CONFIG_KEY_RESUME_VALUE = sys.intern("__pregel_resume_value")
|
||||
# holds the value that "answers" an interrupt() call
|
||||
|
||||
# --- Other constants ---
|
||||
PUSH = sys.intern("__pregel_push")
|
||||
@@ -79,12 +87,17 @@ NS_END = sys.intern(":")
|
||||
# for checkpoint_ns, for each level, separates the namespace from the task_id
|
||||
CONF = cast(Literal["configurable"], sys.intern("configurable"))
|
||||
# key for the configurable dict in RunnableConfig
|
||||
FF_SEND_V2 = getenv("LANGGRAPH_FF_SEND_V2", "false").lower() == "true"
|
||||
# temporary flag to enable new Send semantics
|
||||
NULL_TASK_ID = sys.intern("00000000-0000-0000-0000-000000000000")
|
||||
# the task_id to use for writes that are not associated with a task
|
||||
|
||||
RESERVED = {
|
||||
TAG_HIDDEN,
|
||||
# reserved write keys
|
||||
INPUT,
|
||||
INTERRUPT,
|
||||
RESUME,
|
||||
ERROR,
|
||||
NO_WRITES,
|
||||
SCHEDULED,
|
||||
|
||||
@@ -2,7 +2,7 @@ from enum import Enum
|
||||
from typing import Any, Sequence
|
||||
|
||||
from langgraph.checkpoint.base import EmptyChannelError # noqa: F401
|
||||
from langgraph.types import Interrupt
|
||||
from langgraph.types import Command, Interrupt
|
||||
|
||||
# EmptyChannelError re-exported for backwards compatibility
|
||||
|
||||
@@ -58,7 +58,11 @@ class InvalidUpdateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphInterrupt(Exception):
|
||||
class GraphBubbleUp(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GraphInterrupt(GraphBubbleUp):
|
||||
"""Raised when a subgraph is interrupted, suppressed by the root graph.
|
||||
Never raised directly, or surfaced to the user."""
|
||||
|
||||
@@ -70,16 +74,23 @@ class NodeInterrupt(GraphInterrupt):
|
||||
"""Raised by a node to interrupt execution."""
|
||||
|
||||
def __init__(self, value: Any) -> None:
|
||||
super().__init__([Interrupt(value)])
|
||||
super().__init__([Interrupt(value=value)])
|
||||
|
||||
|
||||
class GraphDelegate(Exception):
|
||||
class GraphDelegate(GraphBubbleUp):
|
||||
"""Raised when a graph is delegated (for distributed mode)."""
|
||||
|
||||
def __init__(self, *args: dict[str, Any]) -> None:
|
||||
super().__init__(*args)
|
||||
|
||||
|
||||
class ParentCommand(GraphBubbleUp):
|
||||
args: tuple[Command]
|
||||
|
||||
def __init__(self, command: Command) -> None:
|
||||
super().__init__(command)
|
||||
|
||||
|
||||
class EmptyInputError(Exception):
|
||||
"""Raised when graph receives an empty input."""
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from langgraph.graph.graph import END, START, Graph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.graph.state import StateGraph
|
||||
from langgraph.graph.state import GraphCommand, StateGraph
|
||||
|
||||
__all__ = [
|
||||
"END",
|
||||
"START",
|
||||
"Graph",
|
||||
"StateGraph",
|
||||
"GraphCommand",
|
||||
"MessageGraph",
|
||||
"add_messages",
|
||||
"MessagesState",
|
||||
|
||||
@@ -27,6 +27,7 @@ from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.constants import (
|
||||
EMPTY_SEQ,
|
||||
END,
|
||||
NS_END,
|
||||
NS_SEP,
|
||||
@@ -47,6 +48,7 @@ logger = logging.getLogger(__name__)
|
||||
class NodeSpec(NamedTuple):
|
||||
runnable: Runnable
|
||||
metadata: Optional[dict[str, Any]] = None
|
||||
ends: Optional[tuple[str, ...]] = EMPTY_SEQ
|
||||
|
||||
|
||||
class Branch(NamedTuple):
|
||||
@@ -123,7 +125,7 @@ class Branch(NamedTuple):
|
||||
result: Any,
|
||||
config: RunnableConfig,
|
||||
) -> Union[Runnable, Any]:
|
||||
if not isinstance(result, list):
|
||||
if not isinstance(result, (list, tuple)):
|
||||
result = [result]
|
||||
if self.ends:
|
||||
destinations: Sequence[Union[Send, str]] = [
|
||||
@@ -364,6 +366,9 @@ class Graph:
|
||||
for node in self.nodes:
|
||||
if node != start and node != branch.then:
|
||||
all_sources.add(node)
|
||||
for name, spec in self.nodes.items():
|
||||
if spec.ends:
|
||||
all_sources.add(name)
|
||||
# validate sources
|
||||
for source in all_sources:
|
||||
if source not in self.nodes and source != START:
|
||||
@@ -387,6 +392,9 @@ class Graph:
|
||||
for node in self.nodes:
|
||||
if node != start and node != branch.then:
|
||||
all_targets.add(node)
|
||||
for name, spec in self.nodes.items():
|
||||
if spec.ends:
|
||||
all_targets.update(spec.ends)
|
||||
# validate targets
|
||||
for node in self.nodes:
|
||||
if node not in all_targets:
|
||||
@@ -565,18 +573,12 @@ class CompiledGraph(Pregel):
|
||||
metadata["__interrupt"] = "before"
|
||||
elif key in self.interrupt_after_nodes:
|
||||
metadata["__interrupt"] = "after"
|
||||
if xray:
|
||||
subgraph = (
|
||||
subgraphs[key].get_graph(
|
||||
config=config,
|
||||
xray=xray - 1
|
||||
if isinstance(xray, int)
|
||||
and not isinstance(xray, bool)
|
||||
and xray > 0
|
||||
else xray,
|
||||
)
|
||||
if key in subgraphs
|
||||
else node.get_graph(config=config)
|
||||
if xray and key in subgraphs:
|
||||
subgraph = subgraphs[key].get_graph(
|
||||
config=config,
|
||||
xray=xray - 1
|
||||
if isinstance(xray, int) and not isinstance(xray, bool) and xray > 0
|
||||
else xray,
|
||||
)
|
||||
subgraph.trim_first_node()
|
||||
subgraph.trim_last_node()
|
||||
@@ -620,5 +622,9 @@ class CompiledGraph(Pregel):
|
||||
)
|
||||
if branch.then is not None:
|
||||
add_edge(end, branch.then)
|
||||
for key, n in self.builder.nodes.items():
|
||||
if n.ends:
|
||||
for end in n.ends:
|
||||
add_edge(key, end, conditional=True)
|
||||
|
||||
return graph
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import dataclasses
|
||||
import inspect
|
||||
import logging
|
||||
import typing
|
||||
import warnings
|
||||
from functools import partial
|
||||
from inspect import isclass, isfunction, ismethod, signature
|
||||
from types import FunctionType
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Generic,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
@@ -14,6 +17,7 @@ from typing import (
|
||||
Type,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
overload,
|
||||
@@ -32,8 +36,13 @@ from langgraph.channels.dynamic_barrier_value import DynamicBarrierValue, WaitFo
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.named_barrier_value import NamedBarrierValue
|
||||
from langgraph.constants import NS_END, NS_SEP, TAG_HIDDEN
|
||||
from langgraph.errors import ErrorCode, InvalidUpdateError, create_error_message
|
||||
from langgraph.constants import EMPTY_SEQ, NS_END, NS_SEP, SELF, TAG_HIDDEN
|
||||
from langgraph.errors import (
|
||||
ErrorCode,
|
||||
InvalidUpdateError,
|
||||
ParentCommand,
|
||||
create_error_message,
|
||||
)
|
||||
from langgraph.graph.graph import END, START, Branch, CompiledGraph, Graph, Send
|
||||
from langgraph.managed.base import (
|
||||
ChannelKeyPlaceholder,
|
||||
@@ -46,10 +55,10 @@ from langgraph.managed.base import (
|
||||
from langgraph.pregel.read import ChannelRead, PregelNode
|
||||
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import All, Checkpointer, RetryPolicy
|
||||
from langgraph.types import _DC_KWARGS, All, Checkpointer, Command, N, RetryPolicy
|
||||
from langgraph.utils.fields import get_field_default
|
||||
from langgraph.utils.pydantic import create_model
|
||||
from langgraph.utils.runnable import coerce_to_runnable
|
||||
from langgraph.utils.runnable import RunnableCallable, coerce_to_runnable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,11 +75,37 @@ def _warn_invalid_state_schema(schema: Union[Type[Any], Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _get_node_name(node: RunnableLike) -> str:
|
||||
if isinstance(node, Runnable):
|
||||
return node.get_name()
|
||||
elif callable(node):
|
||||
return getattr(node, "__name__", node.__class__.__name__)
|
||||
else:
|
||||
raise TypeError(f"Unsupported node type: {type(node)}")
|
||||
|
||||
|
||||
@dataclasses.dataclass(**_DC_KWARGS)
|
||||
class GraphCommand(Generic[N], Command[N]):
|
||||
"""One or more commands to update a StateGraph's state and go to, or send messages to nodes."""
|
||||
|
||||
goto: Union[str, Sequence[str]] = ()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
# get all non-None values
|
||||
contents = ", ".join(
|
||||
f"{key}={value!r}"
|
||||
for key, value in dataclasses.asdict(self).items()
|
||||
if value
|
||||
)
|
||||
return f"Command({contents})"
|
||||
|
||||
|
||||
class StateNodeSpec(NamedTuple):
|
||||
runnable: Runnable
|
||||
metadata: Optional[dict[str, Any]]
|
||||
input: Type[Any]
|
||||
retry_policy: Optional[RetryPolicy]
|
||||
ends: Optional[tuple[str, ...]] = EMPTY_SEQ
|
||||
|
||||
|
||||
class StateGraph(Graph):
|
||||
@@ -222,7 +257,7 @@ class StateGraph(Graph):
|
||||
ValueError: If the key is already being used as a state key.
|
||||
|
||||
Returns:
|
||||
None
|
||||
StateGraph
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -246,7 +281,7 @@ class StateGraph(Graph):
|
||||
ValueError: If the key is already being used as a state key.
|
||||
|
||||
Returns:
|
||||
None
|
||||
StateGraph
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -299,7 +334,7 @@ class StateGraph(Graph):
|
||||
```
|
||||
|
||||
Returns:
|
||||
None
|
||||
StateGraph
|
||||
"""
|
||||
if not isinstance(node, str):
|
||||
action = node
|
||||
@@ -338,8 +373,33 @@ class StateGraph(Graph):
|
||||
f"'{character}' is a reserved character and is not allowed in the node names."
|
||||
)
|
||||
|
||||
if input is None:
|
||||
input = _get_input_schema_from_type_hint(action)
|
||||
ends = EMPTY_SEQ
|
||||
try:
|
||||
if (isfunction(action) or ismethod(getattr(action, "__call__", None))) and (
|
||||
hints := get_type_hints(getattr(action, "__call__"))
|
||||
or get_type_hints(action)
|
||||
):
|
||||
if input is None:
|
||||
first_parameter_name = next(
|
||||
iter(
|
||||
inspect.signature(
|
||||
cast(FunctionType, action)
|
||||
).parameters.keys()
|
||||
)
|
||||
)
|
||||
if input_hint := hints.get(first_parameter_name):
|
||||
if isinstance(input_hint, type) and get_type_hints(input_hint):
|
||||
input = input_hint
|
||||
if (
|
||||
(rtn := hints.get("return"))
|
||||
and get_origin(rtn) in (Command, GraphCommand)
|
||||
and (rargs := get_args(rtn))
|
||||
and get_origin(rargs[0]) is Literal
|
||||
and (vals := get_args(rargs[0]))
|
||||
):
|
||||
ends = vals
|
||||
except (TypeError, StopIteration):
|
||||
pass
|
||||
if input is not None:
|
||||
self._add_schema(input)
|
||||
self.nodes[cast(str, node)] = StateNodeSpec(
|
||||
@@ -347,6 +407,7 @@ class StateGraph(Graph):
|
||||
metadata,
|
||||
input=input or self.schema,
|
||||
retry_policy=retry,
|
||||
ends=ends,
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -363,7 +424,7 @@ class StateGraph(Graph):
|
||||
ValueError: If the start key is 'END' or if the start key or end key is not present in the graph.
|
||||
|
||||
Returns:
|
||||
None
|
||||
StateGraph
|
||||
"""
|
||||
if isinstance(start_key, str):
|
||||
return super().add_edge(start_key, end_key)
|
||||
@@ -386,6 +447,48 @@ class StateGraph(Graph):
|
||||
self.waiting_edges.add((tuple(start_key), end_key))
|
||||
return self
|
||||
|
||||
def add_sequence(
|
||||
self,
|
||||
nodes: Sequence[Union[RunnableLike, tuple[str, RunnableLike]]],
|
||||
) -> Self:
|
||||
"""Add a sequence of nodes that will be executed in the provided order.
|
||||
|
||||
Args:
|
||||
nodes: A sequence of RunnableLike objects (e.g. a LangChain Runnable or a callable) or (name, RunnableLike) tuples.
|
||||
If no names are provided, the name will be inferred from the node object (e.g. a runnable or a callable name).
|
||||
Each node will be executed in the order provided.
|
||||
|
||||
Raises:
|
||||
ValueError: if the sequence is empty.
|
||||
ValueError: if the sequence contains duplicate node names.
|
||||
|
||||
Returns:
|
||||
StateGraph
|
||||
"""
|
||||
if len(nodes) < 1:
|
||||
raise ValueError("Sequence requires at least one node.")
|
||||
|
||||
previous_name: Optional[str] = None
|
||||
for node in nodes:
|
||||
if isinstance(node, tuple) and len(node) == 2:
|
||||
name, node = node
|
||||
else:
|
||||
name = _get_node_name(node)
|
||||
|
||||
if name in self.nodes:
|
||||
raise ValueError(
|
||||
f"Node names must be unique: node with the name '{name}' already exists. "
|
||||
"If you need to use two different runnables/callables with the same name (for example, using `lambda`), please provide them as tuples (name, runnable/callable)."
|
||||
)
|
||||
|
||||
self.add_node(name, node)
|
||||
if previous_name is not None:
|
||||
self.add_edge(previous_name, name)
|
||||
|
||||
previous_name = name
|
||||
|
||||
return self
|
||||
|
||||
def compile(
|
||||
self,
|
||||
checkpointer: Checkpointer = None,
|
||||
@@ -401,9 +504,11 @@ class StateGraph(Graph):
|
||||
streamed, batched, and run asynchronously.
|
||||
|
||||
Args:
|
||||
checkpointer (Checkpointer): An optional checkpoint saver object.
|
||||
This serves as a fully versioned "memory" for the graph, allowing
|
||||
the graph to be paused and resumed, and replayed from any point.
|
||||
checkpointer (Optional[Union[Checkpointer, Literal[False]]]): A checkpoint saver object or flag.
|
||||
If provided, this Checkpointer serves as a fully versioned "short-term memory" for the graph,
|
||||
allowing it to be paused, resumed, and replayed from any point.
|
||||
If None, it may inherit the parent graph's checkpointer when used as a subgraph.
|
||||
If False, it will not use or inherit any checkpointer.
|
||||
interrupt_before (Optional[Sequence[str]]): An optional list of node names to interrupt before.
|
||||
interrupt_after (Optional[Sequence[str]]): An optional list of node names to interrupt after.
|
||||
debug (bool): A flag indicating whether to enable debug mode.
|
||||
@@ -468,6 +573,9 @@ class StateGraph(Graph):
|
||||
for key, node in self.nodes.items():
|
||||
compiled.attach_node(key, node)
|
||||
|
||||
for key, node in self.nodes.items():
|
||||
compiled.attach_branch(key, SELF, CONTROL_BRANCH, with_reader=False)
|
||||
|
||||
for start, end in self.edges:
|
||||
compiled.attach_edge(start, end)
|
||||
|
||||
@@ -518,11 +626,30 @@ class CompiledStateGraph(CompiledGraph):
|
||||
if is_writable_managed_value(v)
|
||||
]
|
||||
|
||||
def _get_root(input: Any) -> Any:
|
||||
if isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return SKIP_WRITE
|
||||
return input.update
|
||||
else:
|
||||
return input
|
||||
|
||||
# to avoid name collision below
|
||||
node_key = key
|
||||
|
||||
def _get_state_key(input: Union[None, dict, Any], *, key: str) -> Any:
|
||||
if input is None:
|
||||
return SKIP_WRITE
|
||||
elif isinstance(input, dict):
|
||||
if all(k not in output_keys for k in input):
|
||||
raise InvalidUpdateError(
|
||||
f"Expected node {node_key} to update at least one of {output_keys}, got {input}"
|
||||
)
|
||||
return input.get(key, SKIP_WRITE)
|
||||
elif isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return SKIP_WRITE
|
||||
return _get_state_key(input.update, key=key)
|
||||
elif get_type_hints(type(input)):
|
||||
value = getattr(input, key, SKIP_WRITE)
|
||||
return value if value is not None else SKIP_WRITE
|
||||
@@ -535,7 +662,7 @@ class CompiledStateGraph(CompiledGraph):
|
||||
|
||||
# state updaters
|
||||
write_entries = (
|
||||
[ChannelWriteEntry("__root__", skip_none=True)]
|
||||
[ChannelWriteEntry("__root__", skip_none=True, mapper=_get_root)]
|
||||
if output_keys == ["__root__"]
|
||||
else [
|
||||
ChannelWriteEntry(key, mapper=partial(_get_state_key, key=key))
|
||||
@@ -578,7 +705,6 @@ class CompiledStateGraph(CompiledGraph):
|
||||
ChannelWrite(
|
||||
[ChannelWriteEntry(key, key)] + write_entries,
|
||||
tags=[TAG_HIDDEN],
|
||||
require_at_least_one_of=output_keys,
|
||||
),
|
||||
],
|
||||
metadata=node.metadata,
|
||||
@@ -615,7 +741,9 @@ class CompiledStateGraph(CompiledGraph):
|
||||
[ChannelWriteEntry(channel_name, start)], tags=[TAG_HIDDEN]
|
||||
)
|
||||
|
||||
def attach_branch(self, start: str, name: str, branch: Branch) -> None:
|
||||
def attach_branch(
|
||||
self, start: str, name: str, branch: Branch, *, with_reader: bool = True
|
||||
) -> None:
|
||||
def branch_writer(
|
||||
packets: Sequence[Union[str, Send]], config: RunnableConfig
|
||||
) -> None:
|
||||
@@ -648,7 +776,8 @@ class CompiledStateGraph(CompiledGraph):
|
||||
else self.builder.schema
|
||||
)
|
||||
self.nodes[start] |= branch.run(
|
||||
branch_writer, _get_state_reader(self.builder, schema)
|
||||
branch_writer,
|
||||
_get_state_reader(self.builder, schema) if with_reader else None,
|
||||
)
|
||||
|
||||
# attach branch subscribers
|
||||
@@ -697,6 +826,50 @@ def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
|
||||
return schema(**input)
|
||||
|
||||
|
||||
def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
if isinstance(value, Send):
|
||||
return [value]
|
||||
if not isinstance(value, GraphCommand):
|
||||
return EMPTY_SEQ
|
||||
if value.graph == Command.PARENT:
|
||||
raise ParentCommand(value)
|
||||
rtn: list[Union[str, Send]] = []
|
||||
if isinstance(value.goto, str):
|
||||
rtn.append(value.goto)
|
||||
else:
|
||||
rtn.extend(value.goto)
|
||||
if isinstance(value.send, Send):
|
||||
rtn.append(value.send)
|
||||
else:
|
||||
rtn.extend(value.send)
|
||||
return rtn
|
||||
|
||||
|
||||
async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
if isinstance(value, Send):
|
||||
return [value]
|
||||
if not isinstance(value, GraphCommand):
|
||||
return EMPTY_SEQ
|
||||
if value.graph == Command.PARENT:
|
||||
raise ParentCommand(value)
|
||||
rtn: list[Union[str, Send]] = []
|
||||
if isinstance(value.goto, str):
|
||||
rtn.append(value.goto)
|
||||
else:
|
||||
rtn.extend(value.goto)
|
||||
if isinstance(value.send, Send):
|
||||
rtn.append(value.send)
|
||||
else:
|
||||
rtn.extend(value.send)
|
||||
return rtn
|
||||
|
||||
|
||||
CONTROL_BRANCH_PATH = RunnableCallable(
|
||||
_control_branch, _acontrol_branch, tags=[TAG_HIDDEN], trace=False, recurse=False
|
||||
)
|
||||
CONTROL_BRANCH = Branch(CONTROL_BRANCH_PATH, None)
|
||||
|
||||
|
||||
def _get_channels(
|
||||
schema: Type[dict],
|
||||
) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec]]:
|
||||
@@ -823,21 +996,3 @@ def _get_schema(
|
||||
if k in channels and isinstance(channels[k], BaseChannel)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _get_input_schema_from_type_hint(
|
||||
action: Optional[RunnableLike],
|
||||
) -> Optional[Type[Any]]:
|
||||
if not isfunction(action) and not ismethod(getattr(action, "__call__", None)):
|
||||
return None
|
||||
action = cast(Callable, action)
|
||||
|
||||
try:
|
||||
hints = get_type_hints(getattr(action, "__call__")) or get_type_hints(action)
|
||||
first_parameter_name = next(iter(inspect.signature(action).parameters.keys()))
|
||||
input_hint = hints.get(first_parameter_name)
|
||||
if isinstance(input_hint, type) and get_type_hints(input_hint):
|
||||
return input_hint
|
||||
except (TypeError, StopIteration):
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -37,7 +37,7 @@ from langchain_core.tools import tool as create_tool
|
||||
from langchain_core.tools.base import get_all_basemodel_annotations
|
||||
from typing_extensions import Annotated, get_args, get_origin
|
||||
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.utils.runnable import RunnableCallable
|
||||
|
||||
@@ -275,7 +275,7 @@ class ToolNode(RunnableCallable):
|
||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
||||
except GraphInterrupt as e:
|
||||
except GraphBubbleUp as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
if isinstance(self.handle_tool_errors, tuple):
|
||||
@@ -316,7 +316,7 @@ class ToolNode(RunnableCallable):
|
||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
||||
except GraphInterrupt as e:
|
||||
except GraphBubbleUp as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
if isinstance(self.handle_tool_errors, tuple):
|
||||
|
||||
@@ -54,6 +54,7 @@ from langgraph.checkpoint.base import (
|
||||
)
|
||||
from langgraph.constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_CHECKPOINTER,
|
||||
CONFIG_KEY_NODE_FINISHED,
|
||||
@@ -64,9 +65,15 @@ from langgraph.constants import (
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_STREAM_WRITER,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
END,
|
||||
ERROR,
|
||||
INPUT,
|
||||
INTERRUPT,
|
||||
NS_END,
|
||||
NS_SEP,
|
||||
NULL_TASK_ID,
|
||||
PUSH,
|
||||
SCHEDULED,
|
||||
)
|
||||
from langgraph.errors import (
|
||||
ErrorCode,
|
||||
@@ -95,7 +102,14 @@ from langgraph.pregel.utils import find_subgraph_pregel, get_new_channel_version
|
||||
from langgraph.pregel.validate import validate_graph, validate_keys
|
||||
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import All, Checkpointer, LoopProtocol, StateSnapshot, StreamMode
|
||||
from langgraph.types import (
|
||||
All,
|
||||
Checkpointer,
|
||||
LoopProtocol,
|
||||
StateSnapshot,
|
||||
StreamChunk,
|
||||
StreamMode,
|
||||
)
|
||||
from langgraph.utils.config import (
|
||||
ensure_config,
|
||||
merge_configs,
|
||||
@@ -439,6 +453,7 @@ class Pregel(PregelProtocol):
|
||||
config: RunnableConfig,
|
||||
saved: Optional[CheckpointTuple],
|
||||
recurse: Optional[BaseCheckpointSaver] = None,
|
||||
apply_pending_writes: bool = False,
|
||||
) -> StateSnapshot:
|
||||
if not saved:
|
||||
return StateSnapshot(
|
||||
@@ -464,12 +479,16 @@ class Pregel(PregelProtocol):
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
saved.checkpoint,
|
||||
saved.pending_writes or [],
|
||||
self.nodes,
|
||||
channels,
|
||||
managed,
|
||||
saved.config,
|
||||
saved.metadata.get("step", -1) + 1,
|
||||
for_execution=False,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer or None,
|
||||
manager=None,
|
||||
)
|
||||
# get the subgraphs
|
||||
subgraphs = dict(self.get_subgraphs())
|
||||
@@ -503,10 +522,29 @@ class Pregel(PregelProtocol):
|
||||
task_states[task.id] = subgraphs[task.name].get_state(
|
||||
config, subgraphs=True
|
||||
)
|
||||
# apply pending writes
|
||||
if null_writes := [
|
||||
w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
saved.checkpoint,
|
||||
channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
None,
|
||||
)
|
||||
if apply_pending_writes and saved.pending_writes:
|
||||
for tid, k, v in saved.pending_writes:
|
||||
if k in (ERROR, INTERRUPT, SCHEDULED):
|
||||
continue
|
||||
if tid not in next_tasks:
|
||||
continue
|
||||
next_tasks[tid].writes.append((k, v))
|
||||
if tasks := [t for t in next_tasks.values() if t.writes]:
|
||||
apply_writes(saved.checkpoint, channels, tasks, None)
|
||||
# assemble the state snapshot
|
||||
return StateSnapshot(
|
||||
read_channels(channels, self.stream_channels_asis),
|
||||
tuple(t.name for t in next_tasks.values()),
|
||||
tuple(t.name for t in next_tasks.values() if not t.writes),
|
||||
patch_checkpoint_map(saved.config, saved.metadata),
|
||||
saved.metadata,
|
||||
saved.checkpoint["ts"],
|
||||
@@ -524,6 +562,7 @@ class Pregel(PregelProtocol):
|
||||
config: RunnableConfig,
|
||||
saved: Optional[CheckpointTuple],
|
||||
recurse: Optional[BaseCheckpointSaver] = None,
|
||||
apply_pending_writes: bool = False,
|
||||
) -> StateSnapshot:
|
||||
if not saved:
|
||||
return StateSnapshot(
|
||||
@@ -552,12 +591,16 @@ class Pregel(PregelProtocol):
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
saved.checkpoint,
|
||||
saved.pending_writes or [],
|
||||
self.nodes,
|
||||
channels,
|
||||
managed,
|
||||
saved.config,
|
||||
saved.metadata.get("step", -1) + 1,
|
||||
for_execution=False,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer or None,
|
||||
manager=None,
|
||||
)
|
||||
# get the subgraphs
|
||||
subgraphs = {n: g async for n, g in self.aget_subgraphs()}
|
||||
@@ -591,10 +634,29 @@ class Pregel(PregelProtocol):
|
||||
task_states[task.id] = await subgraphs[task.name].aget_state(
|
||||
config, subgraphs=True
|
||||
)
|
||||
# apply pending writes
|
||||
if null_writes := [
|
||||
w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
saved.checkpoint,
|
||||
channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
None,
|
||||
)
|
||||
if apply_pending_writes and saved.pending_writes:
|
||||
for tid, k, v in saved.pending_writes:
|
||||
if k in (ERROR, INTERRUPT, SCHEDULED):
|
||||
continue
|
||||
if tid not in next_tasks:
|
||||
continue
|
||||
next_tasks[tid].writes.append((k, v))
|
||||
if tasks := [t for t in next_tasks.values() if t.writes]:
|
||||
apply_writes(saved.checkpoint, channels, tasks, None)
|
||||
# assemble the state snapshot
|
||||
return StateSnapshot(
|
||||
read_channels(channels, self.stream_channels_asis),
|
||||
tuple(t.name for t in next_tasks.values()),
|
||||
tuple(t.name for t in next_tasks.values() if not t.writes),
|
||||
patch_checkpoint_map(saved.config, saved.metadata),
|
||||
saved.metadata,
|
||||
saved.checkpoint["ts"],
|
||||
@@ -638,7 +700,10 @@ class Pregel(PregelProtocol):
|
||||
config = merge_configs(self.config, config) if self.config else config
|
||||
saved = checkpointer.get_tuple(config)
|
||||
return self._prepare_state_snapshot(
|
||||
config, saved, recurse=checkpointer if subgraphs else None
|
||||
config,
|
||||
saved,
|
||||
recurse=checkpointer if subgraphs else None,
|
||||
apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
|
||||
)
|
||||
|
||||
async def aget_state(
|
||||
@@ -672,7 +737,10 @@ class Pregel(PregelProtocol):
|
||||
config = merge_configs(self.config, config) if self.config else config
|
||||
saved = await checkpointer.aget_tuple(config)
|
||||
return await self._aprepare_state_snapshot(
|
||||
config, saved, recurse=checkpointer if subgraphs else None
|
||||
config,
|
||||
saved,
|
||||
recurse=checkpointer if subgraphs else None,
|
||||
apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
|
||||
)
|
||||
|
||||
def get_state_history(
|
||||
@@ -814,7 +882,7 @@ class Pregel(PregelProtocol):
|
||||
raise ValueError(f"Subgraph {recast_checkpoint_ns} not found")
|
||||
|
||||
# get last checkpoint
|
||||
config = merge_configs(self.config, config) if self.config else config
|
||||
config = ensure_config(self.config, config)
|
||||
saved = checkpointer.get_tuple(config)
|
||||
checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
|
||||
checkpoint_previous_versions = (
|
||||
@@ -826,62 +894,176 @@ class Pregel(PregelProtocol):
|
||||
config,
|
||||
{CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")},
|
||||
)
|
||||
checkpoint_metadata = config["metadata"]
|
||||
if saved:
|
||||
checkpoint_config = patch_configurable(config, saved.config[CONF])
|
||||
# find last node that updated the state, if not provided
|
||||
if values is None and as_node is None:
|
||||
next_config = checkpointer.put(
|
||||
checkpoint_config,
|
||||
create_checkpoint(checkpoint, None, step),
|
||||
{
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {},
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||
elif as_node is None and not any(
|
||||
v for vv in checkpoint["versions_seen"].values() for v in vv.values()
|
||||
):
|
||||
if (
|
||||
isinstance(self.input_channels, str)
|
||||
and self.input_channels in self.nodes
|
||||
):
|
||||
as_node = self.input_channels
|
||||
elif as_node is None:
|
||||
last_seen_by_node = sorted(
|
||||
(v, n)
|
||||
for n, seen in checkpoint["versions_seen"].items()
|
||||
if n in self.nodes
|
||||
for v in seen.values()
|
||||
)
|
||||
# if two nodes updated the state at the same time, it's ambiguous
|
||||
if last_seen_by_node:
|
||||
if len(last_seen_by_node) == 1:
|
||||
as_node = last_seen_by_node[0][1]
|
||||
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
||||
as_node = last_seen_by_node[-1][1]
|
||||
if as_node is None:
|
||||
raise InvalidUpdateError("Ambiguous update, specify as_node")
|
||||
if as_node not in self.nodes:
|
||||
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
||||
# update channels
|
||||
checkpoint_metadata = {**saved.metadata, **checkpoint_metadata}
|
||||
with ChannelsManager(
|
||||
self.channels,
|
||||
checkpoint,
|
||||
LoopProtocol(config=config, step=step + 1, stop=step + 2),
|
||||
) as (
|
||||
channels,
|
||||
managed,
|
||||
):
|
||||
) as (channels, managed):
|
||||
# no values as END, just clear all tasks
|
||||
if values is None and as_node == END:
|
||||
if saved is not None:
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
checkpoint,
|
||||
saved.pending_writes or [],
|
||||
self.nodes,
|
||||
channels,
|
||||
managed,
|
||||
saved.config,
|
||||
saved.metadata.get("step", -1) + 1,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer or None,
|
||||
manager=None,
|
||||
)
|
||||
# apply null writes
|
||||
if null_writes := [
|
||||
w[1:]
|
||||
for w in saved.pending_writes or []
|
||||
if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
saved.checkpoint,
|
||||
channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
None,
|
||||
)
|
||||
# apply writes from tasks that already ran
|
||||
for tid, k, v in saved.pending_writes or []:
|
||||
if k in (ERROR, INTERRUPT, SCHEDULED):
|
||||
continue
|
||||
if tid not in next_tasks:
|
||||
continue
|
||||
next_tasks[tid].writes.append((k, v))
|
||||
# clear all current tasks
|
||||
apply_writes(checkpoint, channels, next_tasks.values(), None)
|
||||
# save checkpoint
|
||||
next_config = checkpointer.put(
|
||||
checkpoint_config,
|
||||
create_checkpoint(checkpoint, None, step),
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {},
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(
|
||||
next_config, saved.metadata if saved else None
|
||||
)
|
||||
# no values, copy checkpoint
|
||||
if values is None and as_node is None:
|
||||
next_checkpoint = create_checkpoint(checkpoint, None, step)
|
||||
# copy checkpoint
|
||||
next_config = checkpointer.put(
|
||||
checkpoint_config,
|
||||
next_checkpoint,
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {},
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(
|
||||
next_config, saved.metadata if saved else None
|
||||
)
|
||||
if values is None and as_node == "__copy__":
|
||||
next_checkpoint = create_checkpoint(checkpoint, None, step)
|
||||
# copy checkpoint
|
||||
next_config = checkpointer.put(
|
||||
saved.parent_config or saved.config if saved else checkpoint_config,
|
||||
next_checkpoint,
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "fork",
|
||||
"step": step + 1,
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(
|
||||
next_config, saved.metadata if saved else None
|
||||
)
|
||||
# apply pending writes, if not on specific checkpoint
|
||||
if (
|
||||
CONFIG_KEY_CHECKPOINT_ID not in config[CONF]
|
||||
and saved is not None
|
||||
and saved.pending_writes
|
||||
):
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
checkpoint,
|
||||
saved.pending_writes,
|
||||
self.nodes,
|
||||
channels,
|
||||
managed,
|
||||
saved.config,
|
||||
saved.metadata.get("step", -1) + 1,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer or None,
|
||||
manager=None,
|
||||
)
|
||||
# apply null writes
|
||||
if null_writes := [
|
||||
w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
saved.checkpoint,
|
||||
channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
None,
|
||||
)
|
||||
# apply writes
|
||||
for tid, k, v in saved.pending_writes:
|
||||
if k in (ERROR, INTERRUPT, SCHEDULED):
|
||||
continue
|
||||
if tid not in next_tasks:
|
||||
continue
|
||||
next_tasks[tid].writes.append((k, v))
|
||||
if tasks := [t for t in next_tasks.values() if t.writes]:
|
||||
apply_writes(checkpoint, channels, tasks, None)
|
||||
# find last node that updated the state, if not provided
|
||||
if as_node is None and not any(
|
||||
v for vv in checkpoint["versions_seen"].values() for v in vv.values()
|
||||
):
|
||||
if (
|
||||
isinstance(self.input_channels, str)
|
||||
and self.input_channels in self.nodes
|
||||
):
|
||||
as_node = self.input_channels
|
||||
elif as_node is None:
|
||||
last_seen_by_node = sorted(
|
||||
(v, n)
|
||||
for n, seen in checkpoint["versions_seen"].items()
|
||||
if n in self.nodes
|
||||
for v in seen.values()
|
||||
)
|
||||
# if two nodes updated the state at the same time, it's ambiguous
|
||||
if last_seen_by_node:
|
||||
if len(last_seen_by_node) == 1:
|
||||
as_node = last_seen_by_node[0][1]
|
||||
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
||||
as_node = last_seen_by_node[-1][1]
|
||||
if as_node is None:
|
||||
raise InvalidUpdateError("Ambiguous update, specify as_node")
|
||||
if as_node not in self.nodes:
|
||||
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
||||
# create task to run all writers of the chosen node
|
||||
writers = self.nodes[as_node].flat_writers
|
||||
if not writers:
|
||||
raise InvalidUpdateError(f"Node {as_node} has no writers")
|
||||
writes: deque[tuple[str, Any]] = deque()
|
||||
task = PregelTaskWrites(as_node, writes, [INTERRUPT])
|
||||
task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
|
||||
task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
|
||||
run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
|
||||
# execute task
|
||||
@@ -910,8 +1092,14 @@ class Pregel(PregelProtocol):
|
||||
),
|
||||
)
|
||||
# save task writes
|
||||
if saved:
|
||||
checkpointer.put_writes(checkpoint_config, task.writes, task_id)
|
||||
# channel writes are saved to current checkpoint
|
||||
# push writes are saved to next checkpoint
|
||||
channel_writes, push_writes = (
|
||||
[w for w in task.writes if w[0] != PUSH],
|
||||
[w for w in task.writes if w[0] == PUSH],
|
||||
)
|
||||
if saved and channel_writes:
|
||||
checkpointer.put_writes(checkpoint_config, channel_writes, task_id)
|
||||
# apply to checkpoint and save
|
||||
mv_writes = apply_writes(
|
||||
checkpoint, channels, [task], checkpointer.get_next_version
|
||||
@@ -922,6 +1110,7 @@ class Pregel(PregelProtocol):
|
||||
checkpoint_config,
|
||||
checkpoint,
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {as_node: values},
|
||||
@@ -931,6 +1120,8 @@ class Pregel(PregelProtocol):
|
||||
checkpoint_previous_versions, checkpoint["channel_versions"]
|
||||
),
|
||||
)
|
||||
if push_writes:
|
||||
checkpointer.put_writes(next_config, push_writes, task_id)
|
||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||
|
||||
async def aupdate_state(
|
||||
@@ -966,7 +1157,7 @@ class Pregel(PregelProtocol):
|
||||
raise ValueError(f"Subgraph {recast_checkpoint_ns} not found")
|
||||
|
||||
# get last checkpoint
|
||||
config = merge_configs(self.config, config) if self.config else config
|
||||
config = ensure_config(self.config, config)
|
||||
saved = await checkpointer.aget_tuple(config)
|
||||
checkpoint = copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
|
||||
checkpoint_previous_versions = (
|
||||
@@ -978,46 +1169,10 @@ class Pregel(PregelProtocol):
|
||||
config,
|
||||
{CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")},
|
||||
)
|
||||
checkpoint_metadata = config["metadata"]
|
||||
if saved:
|
||||
checkpoint_config = patch_configurable(config, saved.config[CONF])
|
||||
# find last node that updated the state, if not provided
|
||||
if values is None and as_node is None:
|
||||
next_config = await checkpointer.aput(
|
||||
checkpoint_config,
|
||||
create_checkpoint(checkpoint, None, step),
|
||||
{
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {},
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||
elif as_node is None and not saved:
|
||||
if (
|
||||
isinstance(self.input_channels, str)
|
||||
and self.input_channels in self.nodes
|
||||
):
|
||||
as_node = self.input_channels
|
||||
elif as_node is None:
|
||||
last_seen_by_node = sorted(
|
||||
(v, n)
|
||||
for n, seen in checkpoint["versions_seen"].items()
|
||||
if n in self.nodes
|
||||
for v in seen.values()
|
||||
)
|
||||
# if two nodes updated the state at the same time, it's ambiguous
|
||||
if last_seen_by_node:
|
||||
if len(last_seen_by_node) == 1:
|
||||
as_node = last_seen_by_node[0][1]
|
||||
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
||||
as_node = last_seen_by_node[-1][1]
|
||||
if as_node is None:
|
||||
raise InvalidUpdateError("Ambiguous update, specify as_node")
|
||||
if as_node not in self.nodes:
|
||||
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
||||
# update channels, acting as the chosen node
|
||||
checkpoint_metadata = {**saved.metadata, **checkpoint_metadata}
|
||||
async with AsyncChannelsManager(
|
||||
self.channels,
|
||||
checkpoint,
|
||||
@@ -1026,12 +1181,164 @@ class Pregel(PregelProtocol):
|
||||
channels,
|
||||
managed,
|
||||
):
|
||||
# no values, just clear all tasks
|
||||
if values is None and as_node == END:
|
||||
if saved is not None:
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
checkpoint,
|
||||
saved.pending_writes or [],
|
||||
self.nodes,
|
||||
channels,
|
||||
managed,
|
||||
saved.config,
|
||||
saved.metadata.get("step", -1) + 1,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer or None,
|
||||
manager=None,
|
||||
)
|
||||
# apply null writes
|
||||
if null_writes := [
|
||||
w[1:]
|
||||
for w in saved.pending_writes or []
|
||||
if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
saved.checkpoint,
|
||||
channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
None,
|
||||
)
|
||||
# apply writes from tasks that already ran
|
||||
for tid, k, v in saved.pending_writes or []:
|
||||
if k in (ERROR, INTERRUPT, SCHEDULED):
|
||||
continue
|
||||
if tid not in next_tasks:
|
||||
continue
|
||||
next_tasks[tid].writes.append((k, v))
|
||||
# clear all current tasks
|
||||
apply_writes(checkpoint, channels, next_tasks.values(), None)
|
||||
# save checkpoint
|
||||
next_config = await checkpointer.aput(
|
||||
checkpoint_config,
|
||||
create_checkpoint(checkpoint, None, step),
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {},
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(
|
||||
next_config, saved.metadata if saved else None
|
||||
)
|
||||
# no values, copy checkpoint
|
||||
if values is None and as_node is None:
|
||||
next_checkpoint = create_checkpoint(checkpoint, None, step)
|
||||
# copy checkpoint
|
||||
next_config = await checkpointer.aput(
|
||||
checkpoint_config,
|
||||
next_checkpoint,
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {},
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(
|
||||
next_config, saved.metadata if saved else None
|
||||
)
|
||||
if values is None and as_node == "__copy__":
|
||||
next_checkpoint = create_checkpoint(checkpoint, None, step)
|
||||
# copy checkpoint
|
||||
next_config = await checkpointer.aput(
|
||||
saved.parent_config or saved.config if saved else checkpoint_config,
|
||||
next_checkpoint,
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "fork",
|
||||
"step": step + 1,
|
||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||
},
|
||||
{},
|
||||
)
|
||||
return patch_checkpoint_map(
|
||||
next_config, saved.metadata if saved else None
|
||||
)
|
||||
# apply pending writes, if not on specific checkpoint
|
||||
if (
|
||||
CONFIG_KEY_CHECKPOINT_ID not in config[CONF]
|
||||
and saved is not None
|
||||
and saved.pending_writes
|
||||
):
|
||||
# tasks for this checkpoint
|
||||
next_tasks = prepare_next_tasks(
|
||||
checkpoint,
|
||||
saved.pending_writes,
|
||||
self.nodes,
|
||||
channels,
|
||||
managed,
|
||||
saved.config,
|
||||
saved.metadata.get("step", -1) + 1,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer or None,
|
||||
manager=None,
|
||||
)
|
||||
# apply null writes
|
||||
if null_writes := [
|
||||
w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
apply_writes(
|
||||
saved.checkpoint,
|
||||
channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
None,
|
||||
)
|
||||
for tid, k, v in saved.pending_writes:
|
||||
if k in (ERROR, INTERRUPT, SCHEDULED):
|
||||
continue
|
||||
if tid not in next_tasks:
|
||||
continue
|
||||
next_tasks[tid].writes.append((k, v))
|
||||
if tasks := [t for t in next_tasks.values() if t.writes]:
|
||||
apply_writes(checkpoint, channels, tasks, None)
|
||||
# find last node that updated the state, if not provided
|
||||
if as_node is None and not saved:
|
||||
if (
|
||||
isinstance(self.input_channels, str)
|
||||
and self.input_channels in self.nodes
|
||||
):
|
||||
as_node = self.input_channels
|
||||
elif as_node is None:
|
||||
last_seen_by_node = sorted(
|
||||
(v, n)
|
||||
for n, seen in checkpoint["versions_seen"].items()
|
||||
if n in self.nodes
|
||||
for v in seen.values()
|
||||
)
|
||||
# if two nodes updated the state at the same time, it's ambiguous
|
||||
if last_seen_by_node:
|
||||
if len(last_seen_by_node) == 1:
|
||||
as_node = last_seen_by_node[0][1]
|
||||
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
||||
as_node = last_seen_by_node[-1][1]
|
||||
if as_node is None:
|
||||
raise InvalidUpdateError("Ambiguous update, specify as_node")
|
||||
if as_node not in self.nodes:
|
||||
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
||||
# create task to run all writers of the chosen node
|
||||
writers = self.nodes[as_node].flat_writers
|
||||
if not writers:
|
||||
raise InvalidUpdateError(f"Node {as_node} has no writers")
|
||||
writes: deque[tuple[str, Any]] = deque()
|
||||
task = PregelTaskWrites(as_node, writes, [INTERRUPT])
|
||||
task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
|
||||
task_id = str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
|
||||
run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
|
||||
# execute task
|
||||
@@ -1060,18 +1367,28 @@ class Pregel(PregelProtocol):
|
||||
),
|
||||
)
|
||||
# save task writes
|
||||
if saved:
|
||||
await checkpointer.aput_writes(checkpoint_config, writes, task_id)
|
||||
# channel writes are saved to current checkpoint
|
||||
# push writes are saved to next checkpoint
|
||||
channel_writes, push_writes = (
|
||||
[w for w in task.writes if w[0] != PUSH],
|
||||
[w for w in task.writes if w[0] == PUSH],
|
||||
)
|
||||
if saved and channel_writes:
|
||||
await checkpointer.aput_writes(
|
||||
checkpoint_config, channel_writes, task_id
|
||||
)
|
||||
# apply to checkpoint and save
|
||||
mv_writes = apply_writes(
|
||||
checkpoint, channels, [task], checkpointer.get_next_version
|
||||
)
|
||||
assert not mv_writes, "Can't write to SharedValues from update_state"
|
||||
checkpoint = create_checkpoint(checkpoint, channels, step + 1)
|
||||
# save checkpoint, after applying writes
|
||||
next_config = await checkpointer.aput(
|
||||
checkpoint_config,
|
||||
checkpoint,
|
||||
{
|
||||
**checkpoint_metadata,
|
||||
"source": "update",
|
||||
"step": step + 1,
|
||||
"writes": {as_node: values},
|
||||
@@ -1081,6 +1398,9 @@ class Pregel(PregelProtocol):
|
||||
checkpoint_previous_versions, checkpoint["channel_versions"]
|
||||
),
|
||||
)
|
||||
# save push writes
|
||||
if push_writes:
|
||||
await checkpointer.aput_writes(next_config, push_writes, task_id)
|
||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||
|
||||
def _defaults(
|
||||
@@ -1283,12 +1603,16 @@ class Pregel(PregelProtocol):
|
||||
specs=self.channels,
|
||||
output_keys=output_keys,
|
||||
stream_keys=self.stream_channels_asis,
|
||||
interrupt_before=interrupt_before_,
|
||||
interrupt_after=interrupt_after_,
|
||||
manager=run_manager,
|
||||
debug=debug,
|
||||
) as loop:
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
submit=loop.submit,
|
||||
put_writes=loop.put_writes,
|
||||
schedule_task=loop.accept_push,
|
||||
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
||||
)
|
||||
# enable subgraph streaming
|
||||
@@ -1319,12 +1643,7 @@ class Pregel(PregelProtocol):
|
||||
# channel updates from step N are only visible in step N+1
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# with channel updates applied only at the transition between steps
|
||||
while loop.tick(
|
||||
input_keys=self.input_channels,
|
||||
interrupt_before=interrupt_before_,
|
||||
interrupt_after=interrupt_after_,
|
||||
manager=run_manager,
|
||||
):
|
||||
while loop.tick(input_keys=self.input_channels):
|
||||
for _ in runner.tick(
|
||||
loop.tasks.values(),
|
||||
timeout=self.step_timeout,
|
||||
@@ -1434,6 +1753,10 @@ class Pregel(PregelProtocol):
|
||||
|
||||
stream = AsyncQueue()
|
||||
aioloop = asyncio.get_running_loop()
|
||||
stream_put = cast(
|
||||
Callable[[StreamChunk], None],
|
||||
partial(aioloop.call_soon_threadsafe, stream.put_nowait),
|
||||
)
|
||||
|
||||
def output() -> Iterator:
|
||||
while True:
|
||||
@@ -1488,12 +1811,14 @@ class Pregel(PregelProtocol):
|
||||
# set up messages stream mode
|
||||
if "messages" in stream_modes:
|
||||
run_manager.inheritable_handlers.append(
|
||||
StreamMessagesHandler(stream.put_nowait)
|
||||
StreamMessagesHandler(stream_put)
|
||||
)
|
||||
# set up custom stream mode
|
||||
if "custom" in stream_modes:
|
||||
config[CONF][CONFIG_KEY_STREAM_WRITER] = lambda c: stream.put_nowait(
|
||||
((), "custom", c)
|
||||
config[CONF][CONFIG_KEY_STREAM_WRITER] = (
|
||||
lambda c: aioloop.call_soon_threadsafe(
|
||||
stream.put_nowait, ((), "custom", c)
|
||||
)
|
||||
)
|
||||
async with AsyncPregelLoop(
|
||||
input,
|
||||
@@ -1505,17 +1830,24 @@ class Pregel(PregelProtocol):
|
||||
specs=self.channels,
|
||||
output_keys=output_keys,
|
||||
stream_keys=self.stream_channels_asis,
|
||||
interrupt_before=interrupt_before_,
|
||||
interrupt_after=interrupt_after_,
|
||||
manager=run_manager,
|
||||
debug=debug,
|
||||
) as loop:
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
submit=loop.submit,
|
||||
put_writes=loop.put_writes,
|
||||
schedule_task=loop.accept_push,
|
||||
use_astream=do_stream is not None,
|
||||
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
||||
)
|
||||
# enable subgraph streaming
|
||||
if subgraphs:
|
||||
loop.config[CONF][CONFIG_KEY_STREAM] = loop.stream
|
||||
loop.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(
|
||||
stream_put, stream_modes
|
||||
)
|
||||
# enable concurrent streaming
|
||||
if subgraphs or "messages" in stream_modes or "custom" in stream_modes:
|
||||
|
||||
@@ -1529,12 +1861,7 @@ class Pregel(PregelProtocol):
|
||||
# channel updates from step N are only visible in step N+1
|
||||
# channels are guaranteed to be immutable for the duration of the step,
|
||||
# with channel updates applied only at the transition between steps
|
||||
while loop.tick(
|
||||
input_keys=self.input_channels,
|
||||
interrupt_before=interrupt_before_,
|
||||
interrupt_after=interrupt_after_,
|
||||
manager=run_manager,
|
||||
):
|
||||
while loop.tick(input_keys=self.input_channels):
|
||||
async for _ in runner.atick(
|
||||
loop.tasks.values(),
|
||||
timeout=self.step_timeout,
|
||||
|
||||
@@ -25,6 +25,7 @@ from langgraph.channels.base import BaseChannel
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
PendingWrite,
|
||||
V,
|
||||
copy_checkpoint,
|
||||
)
|
||||
@@ -35,17 +36,21 @@ from langgraph.constants import (
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_CHECKPOINTER,
|
||||
CONFIG_KEY_READ,
|
||||
CONFIG_KEY_RESUME_VALUE,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STORE,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
EMPTY_SEQ,
|
||||
INTERRUPT,
|
||||
MISSING,
|
||||
NO_WRITES,
|
||||
NS_END,
|
||||
NS_SEP,
|
||||
NULL_TASK_ID,
|
||||
PULL,
|
||||
PUSH,
|
||||
RESERVED,
|
||||
RESUME,
|
||||
TAG_HIDDEN,
|
||||
TASKS,
|
||||
Send,
|
||||
@@ -67,6 +72,9 @@ class WritesProtocol(Protocol):
|
||||
"""Protocol for objects containing writes to be applied to checkpoint.
|
||||
Implemented by PregelTaskWrites and PregelExecutableTask."""
|
||||
|
||||
@property
|
||||
def path(self) -> tuple[Union[str, int, tuple], ...]: ...
|
||||
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
|
||||
@@ -81,6 +89,7 @@ class PregelTaskWrites(NamedTuple):
|
||||
"""Simplest implementation of WritesProtocol, for usage with writes that
|
||||
don't originate from a runnable task, eg. graph input, update_state, etc."""
|
||||
|
||||
path: tuple[Union[str, int, tuple], ...]
|
||||
name: str
|
||||
writes: Sequence[tuple[str, Any]]
|
||||
triggers: Sequence[str]
|
||||
@@ -168,7 +177,7 @@ def local_write(
|
||||
"""Function injected under CONFIG_KEY_SEND in task config, to write to channels.
|
||||
Validates writes and forwards them to `commit` function."""
|
||||
for chan, value in writes:
|
||||
if chan == TASKS:
|
||||
if chan in (PUSH, TASKS):
|
||||
if not isinstance(value, Send):
|
||||
raise InvalidUpdateError(f"Expected Send, got {value}")
|
||||
if value.node not in process_keys:
|
||||
@@ -190,6 +199,14 @@ def apply_writes(
|
||||
"""Apply writes from a set of tasks (usually the tasks from a Pregel step)
|
||||
to the checkpoint and channels, and return managed values writes to be applied
|
||||
externally."""
|
||||
# sort tasks on path, to ensure deterministic order for update application
|
||||
# any path parts after the 3rd are ignored for sorting
|
||||
# (we use them for eg. task ids which aren't good for sorting)
|
||||
tasks = sorted(tasks, key=lambda t: t.path[:3])
|
||||
# if no task has triggers this is applying writes from the null task only
|
||||
# so we don't do anything other than update the channels written to
|
||||
bump_step = any(t.triggers for t in tasks)
|
||||
|
||||
# update seen versions
|
||||
for task in tasks:
|
||||
checkpoint["versions_seen"].setdefault(task.name, {}).update(
|
||||
@@ -220,7 +237,7 @@ def apply_writes(
|
||||
)
|
||||
|
||||
# clear pending sends
|
||||
if checkpoint["pending_sends"]:
|
||||
if checkpoint["pending_sends"] and bump_step:
|
||||
checkpoint["pending_sends"].clear()
|
||||
|
||||
# Group writes by channel
|
||||
@@ -228,9 +245,9 @@ def apply_writes(
|
||||
pending_writes_by_managed: dict[str, list[Any]] = defaultdict(list)
|
||||
for task in tasks:
|
||||
for chan, val in task.writes:
|
||||
if chan == NO_WRITES:
|
||||
if chan in (NO_WRITES, PUSH, RESUME, INTERRUPT):
|
||||
pass
|
||||
elif chan == TASKS:
|
||||
elif chan == TASKS: # TODO: remove branch in 1.0
|
||||
checkpoint["pending_sends"].append(val)
|
||||
elif chan in channels:
|
||||
pending_writes_by_channel[chan].append(val)
|
||||
@@ -255,13 +272,14 @@ def apply_writes(
|
||||
updated_channels.add(chan)
|
||||
|
||||
# Channels that weren't updated in this step are notified of a new step
|
||||
for chan in channels:
|
||||
if chan not in updated_channels:
|
||||
if channels[chan].update([]) and get_next_version is not None:
|
||||
checkpoint["channel_versions"][chan] = get_next_version(
|
||||
max_version,
|
||||
channels[chan],
|
||||
)
|
||||
if bump_step:
|
||||
for chan in channels:
|
||||
if chan not in updated_channels:
|
||||
if channels[chan].update([]) and get_next_version is not None:
|
||||
checkpoint["channel_versions"][chan] = get_next_version(
|
||||
max_version,
|
||||
channels[chan],
|
||||
)
|
||||
|
||||
# Return managed values writes to be applied externally
|
||||
return pending_writes_by_managed
|
||||
@@ -270,6 +288,7 @@ def apply_writes(
|
||||
@overload
|
||||
def prepare_next_tasks(
|
||||
checkpoint: Checkpoint,
|
||||
pending_writes: Sequence[PendingWrite],
|
||||
processes: Mapping[str, PregelNode],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
managed: ManagedValueMapping,
|
||||
@@ -286,6 +305,7 @@ def prepare_next_tasks(
|
||||
@overload
|
||||
def prepare_next_tasks(
|
||||
checkpoint: Checkpoint,
|
||||
pending_writes: Sequence[PendingWrite],
|
||||
processes: Mapping[str, PregelNode],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
managed: ManagedValueMapping,
|
||||
@@ -301,6 +321,7 @@ def prepare_next_tasks(
|
||||
|
||||
def prepare_next_tasks(
|
||||
checkpoint: Checkpoint,
|
||||
pending_writes: Sequence[PendingWrite],
|
||||
processes: Mapping[str, PregelNode],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
managed: ManagedValueMapping,
|
||||
@@ -315,13 +336,14 @@ def prepare_next_tasks(
|
||||
"""Prepare the set of tasks that will make up the next Pregel step.
|
||||
This is the union of all PUSH tasks (Sends) and PULL tasks (nodes triggered
|
||||
by edges)."""
|
||||
tasks: dict[str, Union[PregelTask, PregelExecutableTask]] = {}
|
||||
# Consume pending packets
|
||||
for idx, _ in enumerate(checkpoint["pending_sends"]):
|
||||
tasks: list[Union[PregelTask, PregelExecutableTask]] = []
|
||||
# Consume pending_sends from previous step (legacy version of Send)
|
||||
for idx, _ in enumerate(checkpoint["pending_sends"]): # TODO: remove branch in 1.0
|
||||
if task := prepare_single_task(
|
||||
(PUSH, idx),
|
||||
None,
|
||||
checkpoint=checkpoint,
|
||||
pending_writes=pending_writes,
|
||||
processes=processes,
|
||||
channels=channels,
|
||||
managed=managed,
|
||||
@@ -332,7 +354,7 @@ def prepare_next_tasks(
|
||||
checkpointer=checkpointer,
|
||||
manager=manager,
|
||||
):
|
||||
tasks[task.id] = task
|
||||
tasks.append(task)
|
||||
# Check if any processes should be run in next step
|
||||
# If so, prepare the values to be passed to them
|
||||
for name in processes:
|
||||
@@ -340,6 +362,7 @@ def prepare_next_tasks(
|
||||
(PULL, name),
|
||||
None,
|
||||
checkpoint=checkpoint,
|
||||
pending_writes=pending_writes,
|
||||
processes=processes,
|
||||
channels=channels,
|
||||
managed=managed,
|
||||
@@ -350,15 +373,74 @@ def prepare_next_tasks(
|
||||
checkpointer=checkpointer,
|
||||
manager=manager,
|
||||
):
|
||||
tasks[task.id] = task
|
||||
return tasks
|
||||
tasks.append(task)
|
||||
# Consume pending Sends from this step (new version of Send)
|
||||
if any(c == PUSH for _, c, _ in pending_writes):
|
||||
# group writes by task id
|
||||
grouped_by_task = defaultdict(list)
|
||||
for tid, c, _ in pending_writes:
|
||||
grouped_by_task[tid].append(c)
|
||||
# prepare send tasks from grouped writes
|
||||
# 1. start from sends originating from existing tasks
|
||||
tidx = 0
|
||||
while tidx < len(tasks):
|
||||
task = tasks[tidx]
|
||||
if twrites := grouped_by_task.pop(task.id, None):
|
||||
for idx, c in enumerate(twrites):
|
||||
if c != PUSH:
|
||||
continue
|
||||
if next_task := prepare_single_task(
|
||||
(PUSH, task.path, idx, task.id),
|
||||
None,
|
||||
checkpoint=checkpoint,
|
||||
pending_writes=pending_writes,
|
||||
processes=processes,
|
||||
channels=channels,
|
||||
managed=managed,
|
||||
config=config,
|
||||
step=step,
|
||||
for_execution=for_execution,
|
||||
store=store,
|
||||
checkpointer=checkpointer,
|
||||
manager=manager,
|
||||
):
|
||||
tasks.append(next_task)
|
||||
tidx += 1
|
||||
# key tasks by id
|
||||
task_map = {t.id: t for t in tasks}
|
||||
# 2. create new tasks for remaining sends (eg. from update_state)
|
||||
for tid, writes in grouped_by_task.items():
|
||||
task = task_map.get(tid)
|
||||
for idx, c in enumerate(writes):
|
||||
if c != PUSH:
|
||||
continue
|
||||
if next_task := prepare_single_task(
|
||||
(PUSH, task.path if task else (), idx, tid),
|
||||
None,
|
||||
checkpoint=checkpoint,
|
||||
pending_writes=pending_writes,
|
||||
processes=processes,
|
||||
channels=channels,
|
||||
managed=managed,
|
||||
config=config,
|
||||
step=step,
|
||||
for_execution=for_execution,
|
||||
store=store,
|
||||
checkpointer=checkpointer,
|
||||
manager=manager,
|
||||
):
|
||||
task_map[next_task.id] = next_task
|
||||
else:
|
||||
task_map = {t.id: t for t in tasks}
|
||||
return task_map
|
||||
|
||||
|
||||
def prepare_single_task(
|
||||
task_path: tuple[str, Union[int, str]],
|
||||
task_path: tuple[Union[str, int, tuple], ...],
|
||||
task_id_checksum: Optional[str],
|
||||
*,
|
||||
checkpoint: Checkpoint,
|
||||
pending_writes: Sequence[PendingWrite],
|
||||
processes: Mapping[str, PregelNode],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
managed: ManagedValueMapping,
|
||||
@@ -376,31 +458,74 @@ def prepare_single_task(
|
||||
parent_ns = configurable.get(CONFIG_KEY_CHECKPOINT_NS, "")
|
||||
|
||||
if task_path[0] == PUSH:
|
||||
idx = int(task_path[1])
|
||||
if idx >= len(checkpoint["pending_sends"]):
|
||||
return
|
||||
packet = checkpoint["pending_sends"][idx]
|
||||
if not isinstance(packet, Send):
|
||||
logger.warning(
|
||||
f"Ignoring invalid packet type {type(packet)} in pending sends"
|
||||
if len(task_path) == 2: # TODO: remove branch in 1.0
|
||||
# legacy SEND tasks, executed in superstep n+1
|
||||
# (PUSH, idx of pending send)
|
||||
idx = cast(int, task_path[1])
|
||||
if idx >= len(checkpoint["pending_sends"]):
|
||||
return
|
||||
packet = checkpoint["pending_sends"][idx]
|
||||
if not isinstance(packet, Send):
|
||||
logger.warning(
|
||||
f"Ignoring invalid packet type {type(packet)} in pending sends"
|
||||
)
|
||||
return
|
||||
if packet.node not in processes:
|
||||
logger.warning(
|
||||
f"Ignoring unknown node name {packet.node} in pending sends"
|
||||
)
|
||||
return
|
||||
# create task id
|
||||
triggers = [PUSH]
|
||||
checkpoint_ns = (
|
||||
f"{parent_ns}{NS_SEP}{packet.node}" if parent_ns else packet.node
|
||||
)
|
||||
task_id = _uuid5_str(
|
||||
checkpoint_id,
|
||||
checkpoint_ns,
|
||||
str(step),
|
||||
packet.node,
|
||||
PUSH,
|
||||
str(idx),
|
||||
)
|
||||
elif len(task_path) == 4:
|
||||
# new PUSH tasks, executed in superstep n
|
||||
# (PUSH, parent task path, idx of PUSH write, id of parent task)
|
||||
task_path_t = cast(tuple[str, tuple, int, str], task_path)
|
||||
writes_for_path = [w for w in pending_writes if w[0] == task_path_t[3]]
|
||||
if task_path_t[2] >= len(writes_for_path):
|
||||
logger.warning(
|
||||
f"Ignoring invalid write index {task_path[2]} in pending writes"
|
||||
)
|
||||
return
|
||||
packet = writes_for_path[task_path_t[2]][2]
|
||||
if not isinstance(packet, Send):
|
||||
logger.warning(
|
||||
f"Ignoring invalid packet type {type(packet)} in pending writes"
|
||||
)
|
||||
return
|
||||
if packet.node not in processes:
|
||||
logger.warning(
|
||||
f"Ignoring unknown node name {packet.node} in pending writes"
|
||||
)
|
||||
return
|
||||
# create task id
|
||||
triggers = [PUSH]
|
||||
checkpoint_ns = (
|
||||
f"{parent_ns}{NS_SEP}{packet.node}" if parent_ns else packet.node
|
||||
)
|
||||
task_id = _uuid5_str(
|
||||
checkpoint_id,
|
||||
checkpoint_ns,
|
||||
str(step),
|
||||
packet.node,
|
||||
PUSH,
|
||||
_tuple_str(task_path[1]),
|
||||
str(task_path[2]),
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Ignoring invalid PUSH task path {task_path}")
|
||||
return
|
||||
if packet.node not in processes:
|
||||
logger.warning(f"Ignoring unknown node name {packet.node} in pending sends")
|
||||
return
|
||||
# create task id
|
||||
triggers = [PUSH]
|
||||
checkpoint_ns = (
|
||||
f"{parent_ns}{NS_SEP}{packet.node}" if parent_ns else packet.node
|
||||
)
|
||||
task_id = _uuid5_str(
|
||||
checkpoint_id,
|
||||
checkpoint_ns,
|
||||
str(step),
|
||||
packet.node,
|
||||
PUSH,
|
||||
str(idx),
|
||||
)
|
||||
task_checkpoint_ns = f"{checkpoint_ns}:{task_id}"
|
||||
metadata = {
|
||||
"langgraph_step": step,
|
||||
@@ -410,7 +535,7 @@ def prepare_single_task(
|
||||
"langgraph_checkpoint_ns": task_checkpoint_ns,
|
||||
}
|
||||
if task_id_checksum is not None:
|
||||
assert task_id == task_id_checksum
|
||||
assert task_id == task_id_checksum, f"{task_id} != {task_id_checksum}"
|
||||
if for_execution:
|
||||
proc = processes[packet.node]
|
||||
if node := proc.node:
|
||||
@@ -444,7 +569,9 @@ def prepare_single_task(
|
||||
checkpoint,
|
||||
channels,
|
||||
managed,
|
||||
PregelTaskWrites(packet.node, writes, triggers),
|
||||
PregelTaskWrites(
|
||||
task_path, packet.node, writes, triggers
|
||||
),
|
||||
config,
|
||||
),
|
||||
CONFIG_KEY_STORE: (
|
||||
@@ -460,6 +587,14 @@ def prepare_single_task(
|
||||
},
|
||||
CONFIG_KEY_CHECKPOINT_ID: None,
|
||||
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
|
||||
CONFIG_KEY_RESUME_VALUE: next(
|
||||
(
|
||||
v
|
||||
for tid, c, v in pending_writes
|
||||
if tid in (NULL_TASK_ID, task_id) and c == RESUME
|
||||
),
|
||||
MISSING,
|
||||
),
|
||||
},
|
||||
),
|
||||
triggers,
|
||||
@@ -467,11 +602,13 @@ def prepare_single_task(
|
||||
None,
|
||||
task_id,
|
||||
task_path,
|
||||
writers=proc.flat_writers,
|
||||
)
|
||||
|
||||
else:
|
||||
return PregelTask(task_id, packet.node, task_path)
|
||||
elif task_path[0] == PULL:
|
||||
# (PULL, node name)
|
||||
name = cast(str, task_path[1])
|
||||
if name not in processes:
|
||||
return
|
||||
@@ -552,7 +689,7 @@ def prepare_single_task(
|
||||
checkpoint,
|
||||
channels,
|
||||
managed,
|
||||
PregelTaskWrites(name, writes, triggers),
|
||||
PregelTaskWrites(task_path, name, writes, triggers),
|
||||
config,
|
||||
),
|
||||
CONFIG_KEY_STORE: (
|
||||
@@ -568,6 +705,15 @@ def prepare_single_task(
|
||||
},
|
||||
CONFIG_KEY_CHECKPOINT_ID: None,
|
||||
CONFIG_KEY_CHECKPOINT_NS: task_checkpoint_ns,
|
||||
CONFIG_KEY_RESUME_VALUE: next(
|
||||
(
|
||||
v
|
||||
for tid, c, v in pending_writes
|
||||
if tid in (NULL_TASK_ID, task_id)
|
||||
and c == RESUME
|
||||
),
|
||||
MISSING,
|
||||
),
|
||||
},
|
||||
),
|
||||
triggers,
|
||||
@@ -575,6 +721,7 @@ def prepare_single_task(
|
||||
None,
|
||||
task_id,
|
||||
task_path,
|
||||
writers=proc.flat_writers,
|
||||
)
|
||||
else:
|
||||
return PregelTask(task_id, name, task_path)
|
||||
@@ -633,3 +780,12 @@ def _uuid5_str(namespace: bytes, *parts: str) -> str:
|
||||
sha.update(b"".join(p.encode() for p in parts))
|
||||
hex = sha.hexdigest()
|
||||
return f"{hex[:8]}-{hex[8:12]}-{hex[12:16]}-{hex[16:20]}-{hex[20:32]}"
|
||||
|
||||
|
||||
def _tuple_str(tup: Union[str, int, tuple]) -> str:
|
||||
"""Generate a string representation of a tuple."""
|
||||
return (
|
||||
f"({', '.join(_tuple_str(x) for x in tup)})"
|
||||
if isinstance(tup, (tuple, list))
|
||||
else str(tup)
|
||||
)
|
||||
|
||||
@@ -191,6 +191,14 @@ def map_debug_checkpoint(
|
||||
"state": t.state,
|
||||
}
|
||||
if t.error
|
||||
else {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"result": t.result,
|
||||
"interrupts": tuple(asdict(i) for i in t.interrupts),
|
||||
"state": t.state,
|
||||
}
|
||||
if t.result
|
||||
else {
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
@@ -208,7 +216,7 @@ def print_step_tasks(step: int, next_tasks: list[PregelExecutableTask]) -> None:
|
||||
print(
|
||||
f"{get_colored_text(f'[{step}:tasks]', color='blue')} "
|
||||
+ get_bolded_text(
|
||||
f"Starting step {step} with {n_tasks} task{'s' if n_tasks != 1 else ''}:\n"
|
||||
f"Starting {n_tasks} task{'s' if n_tasks != 1 else ''} for step {step}:\n"
|
||||
)
|
||||
+ "\n".join(
|
||||
f"- {get_colored_text(task.name, 'green')} -> {pformat(task.input)}"
|
||||
|
||||
@@ -20,7 +20,7 @@ from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.runnables.config import get_executor_for_config
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
P = ParamSpec("P")
|
||||
T = TypeVar("T")
|
||||
@@ -68,7 +68,7 @@ class BackgroundExecutor(ContextManager):
|
||||
def done(self, task: concurrent.futures.Future) -> None:
|
||||
try:
|
||||
task.result()
|
||||
except GraphInterrupt:
|
||||
except GraphBubbleUp:
|
||||
# This exception is an interruption signal, not an error
|
||||
# so we don't want to re-raise it on exit
|
||||
self.tasks.pop(task)
|
||||
@@ -155,7 +155,7 @@ class AsyncBackgroundExecutor(AsyncContextManager):
|
||||
if exc := task.exception():
|
||||
# This exception is an interruption signal, not an error
|
||||
# so we don't want to re-raise it on exit
|
||||
if isinstance(exc, GraphInterrupt):
|
||||
if isinstance(exc, GraphBubbleUp):
|
||||
self.tasks.pop(task)
|
||||
else:
|
||||
self.tasks.pop(task)
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
from typing import Any, Iterator, Literal, Mapping, Optional, Sequence, TypeVar, Union
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.runnables.utils import AddableDict
|
||||
|
||||
from langgraph.channels.base import BaseChannel, EmptyChannelError
|
||||
from langgraph.constants import EMPTY_SEQ, ERROR, INTERRUPT, TAG_HIDDEN
|
||||
from langgraph.constants import (
|
||||
EMPTY_SEQ,
|
||||
ERROR,
|
||||
FF_SEND_V2,
|
||||
INTERRUPT,
|
||||
NULL_TASK_ID,
|
||||
PUSH,
|
||||
RESUME,
|
||||
TAG_HIDDEN,
|
||||
TASKS,
|
||||
)
|
||||
from langgraph.errors import InvalidUpdateError
|
||||
from langgraph.pregel.log import logger
|
||||
from langgraph.types import PregelExecutableTask
|
||||
from langgraph.types import Command, PregelExecutableTask, Send
|
||||
|
||||
|
||||
def is_task_id(task_id: str) -> bool:
|
||||
"""Check if a string is a valid task id."""
|
||||
try:
|
||||
UUID(task_id)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_channel(
|
||||
@@ -44,6 +65,38 @@ def read_channels(
|
||||
return values
|
||||
|
||||
|
||||
def map_command(
|
||||
cmd: Command,
|
||||
) -> Iterator[tuple[str, str, Any]]:
|
||||
"""Map input chunk to a sequence of pending writes in the form (channel, value)."""
|
||||
if cmd.graph == Command.PARENT:
|
||||
raise InvalidUpdateError("There is not parent graph")
|
||||
if cmd.send:
|
||||
if isinstance(cmd.send, (tuple, list)):
|
||||
sends = cmd.send
|
||||
else:
|
||||
sends = [cmd.send]
|
||||
for send in sends:
|
||||
if not isinstance(send, Send):
|
||||
raise TypeError(
|
||||
f"In Command.send, expected Send, got {type(send).__name__}"
|
||||
)
|
||||
yield (NULL_TASK_ID, PUSH if FF_SEND_V2 else TASKS, send)
|
||||
if cmd.resume:
|
||||
if isinstance(cmd.resume, dict) and all(is_task_id(k) for k in cmd.resume):
|
||||
for tid, resume in cmd.resume.items():
|
||||
yield (tid, RESUME, resume)
|
||||
else:
|
||||
yield (NULL_TASK_ID, RESUME, cmd.resume)
|
||||
if cmd.update:
|
||||
if not isinstance(cmd.update, dict):
|
||||
raise TypeError(
|
||||
f"Expected cmd.update to be a dict mapping channel names to update values, got {type(cmd.update).__name__}"
|
||||
)
|
||||
for k, v in cmd.update.items():
|
||||
yield (NULL_TASK_ID, k, v)
|
||||
|
||||
|
||||
def map_input(
|
||||
input_channels: Union[str, Sequence[str]],
|
||||
chunk: Optional[Union[dict[str, Any], Any]],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from collections import deque
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import AsyncExitStack, ExitStack
|
||||
from types import TracebackType
|
||||
from typing import (
|
||||
@@ -52,6 +52,9 @@ from langgraph.constants import (
|
||||
INPUT,
|
||||
INTERRUPT,
|
||||
NS_SEP,
|
||||
NULL_TASK_ID,
|
||||
PUSH,
|
||||
RESUME,
|
||||
SCHEDULED,
|
||||
TAG_HIDDEN,
|
||||
)
|
||||
@@ -74,6 +77,7 @@ from langgraph.pregel.algo import (
|
||||
apply_writes,
|
||||
increment,
|
||||
prepare_next_tasks,
|
||||
prepare_single_task,
|
||||
should_interrupt,
|
||||
)
|
||||
from langgraph.pregel.debug import (
|
||||
@@ -90,6 +94,7 @@ from langgraph.pregel.executor import (
|
||||
Submit,
|
||||
)
|
||||
from langgraph.pregel.io import (
|
||||
map_command,
|
||||
map_input,
|
||||
map_output_updates,
|
||||
map_output_values,
|
||||
@@ -100,12 +105,18 @@ from langgraph.pregel.manager import AsyncChannelsManager, ChannelsManager
|
||||
from langgraph.pregel.read import PregelNode
|
||||
from langgraph.pregel.utils import get_new_channel_versions
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import All, LoopProtocol, PregelExecutableTask, StreamProtocol
|
||||
from langgraph.types import (
|
||||
All,
|
||||
Command,
|
||||
LoopProtocol,
|
||||
PregelExecutableTask,
|
||||
StreamChunk,
|
||||
StreamProtocol,
|
||||
)
|
||||
from langgraph.utils.config import patch_configurable
|
||||
|
||||
V = TypeVar("V")
|
||||
P = ParamSpec("P")
|
||||
StreamChunk = tuple[tuple[str, ...], str, Any]
|
||||
|
||||
INPUT_DONE = object()
|
||||
INPUT_RESUMING = object()
|
||||
@@ -130,6 +141,9 @@ class PregelLoop(LoopProtocol):
|
||||
stream_keys: Union[str, Sequence[str]]
|
||||
skip_done_tasks: bool
|
||||
is_nested: bool
|
||||
manager: Union[None, AsyncParentRunManager, ParentRunManager]
|
||||
interrupt_after: Union[All, Sequence[str]]
|
||||
interrupt_before: Union[All, Sequence[str]]
|
||||
|
||||
checkpointer_get_next_version: GetNextVersion
|
||||
checkpointer_put_writes: Optional[
|
||||
@@ -162,6 +176,7 @@ class PregelLoop(LoopProtocol):
|
||||
"pending", "done", "interrupt_before", "interrupt_after", "out_of_steps"
|
||||
]
|
||||
tasks: dict[str, PregelExecutableTask]
|
||||
to_interrupt: list[PregelExecutableTask]
|
||||
output: Union[None, dict[str, Any], Any] = None
|
||||
|
||||
# public
|
||||
@@ -178,6 +193,9 @@ class PregelLoop(LoopProtocol):
|
||||
specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]],
|
||||
output_keys: Union[str, Sequence[str]],
|
||||
stream_keys: Union[str, Sequence[str]],
|
||||
interrupt_after: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
interrupt_before: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
manager: Union[None, AsyncParentRunManager, ParentRunManager] = None,
|
||||
check_subgraphs: bool = True,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
@@ -194,6 +212,9 @@ class PregelLoop(LoopProtocol):
|
||||
self.specs = specs
|
||||
self.output_keys = output_keys
|
||||
self.stream_keys = stream_keys
|
||||
self.interrupt_after = interrupt_after
|
||||
self.interrupt_before = interrupt_before
|
||||
self.manager = manager
|
||||
self.is_nested = CONFIG_KEY_TASK_ID in self.config.get(CONF, {})
|
||||
self.skip_done_tasks = (
|
||||
CONFIG_KEY_CHECKPOINT_ID not in config[CONF]
|
||||
@@ -261,15 +282,60 @@ class PregelLoop(LoopProtocol):
|
||||
task_id,
|
||||
)
|
||||
# output writes
|
||||
self._output_writes(task_id, writes)
|
||||
if hasattr(self, "tasks"):
|
||||
self._output_writes(task_id, writes)
|
||||
|
||||
def accept_push(
|
||||
self, task: PregelExecutableTask, write_idx: int
|
||||
) -> Optional[PregelExecutableTask]:
|
||||
"""Accept a PUSH from a task, potentially returning a new task to start."""
|
||||
# don't start if an earlier PUSH has already triggered an interrupt
|
||||
if self.to_interrupt:
|
||||
return
|
||||
# don't start if we should interrupt *after* the original task
|
||||
if should_interrupt(self.checkpoint, self.interrupt_after, [task]):
|
||||
self.to_interrupt.append(task)
|
||||
return
|
||||
if pushed := cast(
|
||||
Optional[PregelExecutableTask],
|
||||
prepare_single_task(
|
||||
(PUSH, task.path, write_idx, task.id),
|
||||
None,
|
||||
checkpoint=self.checkpoint,
|
||||
pending_writes=[(task.id, *w) for w in task.writes],
|
||||
processes=self.nodes,
|
||||
channels=self.channels,
|
||||
managed=self.managed,
|
||||
config=self.config,
|
||||
step=self.step,
|
||||
for_execution=True,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
manager=self.manager,
|
||||
),
|
||||
):
|
||||
# don't start if we should interrupt *before* the new task
|
||||
if should_interrupt(self.checkpoint, self.interrupt_before, [pushed]):
|
||||
self.to_interrupt.append(pushed)
|
||||
return
|
||||
# produce debug output
|
||||
self._emit("debug", map_debug_tasks, self.step, [pushed])
|
||||
# debug flag
|
||||
if self.debug:
|
||||
print_step_tasks(self.step, [pushed])
|
||||
# save the new task
|
||||
self.tasks[pushed.id] = pushed
|
||||
# match any pending writes to the new task
|
||||
if self.skip_done_tasks:
|
||||
self._match_writes({pushed.id: pushed})
|
||||
# return the new task, to be started, if not run before
|
||||
if not pushed.writes:
|
||||
return pushed
|
||||
|
||||
def tick(
|
||||
self,
|
||||
*,
|
||||
input_keys: Union[str, Sequence[str]],
|
||||
interrupt_after: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
interrupt_before: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
manager: Union[None, AsyncParentRunManager, ParentRunManager] = None,
|
||||
) -> bool:
|
||||
"""Execute a single iteration of the Pregel loop.
|
||||
Returns True if more iterations are needed."""
|
||||
@@ -278,6 +344,10 @@ class PregelLoop(LoopProtocol):
|
||||
|
||||
if self.input not in (INPUT_DONE, INPUT_RESUMING):
|
||||
self._first(input_keys=input_keys)
|
||||
elif self.to_interrupt:
|
||||
# if we need to interrupt, do so
|
||||
self.status = "interrupt_before"
|
||||
raise GraphInterrupt()
|
||||
elif all(task.writes for task in self.tasks.values()):
|
||||
writes = [w for t in self.tasks.values() for w in t.writes]
|
||||
# debug flag
|
||||
@@ -307,6 +377,8 @@ class PregelLoop(LoopProtocol):
|
||||
)
|
||||
# clear pending writes
|
||||
self.checkpoint_pending_writes.clear()
|
||||
# "not skip_done_tasks" only applies to first tick after resuming
|
||||
self.skip_done_tasks = True
|
||||
# save checkpoint
|
||||
self._put_checkpoint(
|
||||
{
|
||||
@@ -320,7 +392,9 @@ class PregelLoop(LoopProtocol):
|
||||
}
|
||||
)
|
||||
# after execution, check if we should interrupt
|
||||
if should_interrupt(self.checkpoint, interrupt_after, self.tasks.values()):
|
||||
if should_interrupt(
|
||||
self.checkpoint, self.interrupt_after, self.tasks.values()
|
||||
):
|
||||
self.status = "interrupt_after"
|
||||
raise GraphInterrupt()
|
||||
else:
|
||||
@@ -331,19 +405,33 @@ class PregelLoop(LoopProtocol):
|
||||
self.status = "out_of_steps"
|
||||
return False
|
||||
|
||||
# apply NULL writes
|
||||
if null_writes := [
|
||||
w[1:] for w in self.checkpoint_pending_writes if w[0] == NULL_TASK_ID
|
||||
]:
|
||||
mv_writes = apply_writes(
|
||||
self.checkpoint,
|
||||
self.channels,
|
||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||
self.checkpointer_get_next_version,
|
||||
)
|
||||
for key, values in mv_writes.items():
|
||||
self._update_mv(key, values)
|
||||
# prepare next tasks
|
||||
self.tasks = prepare_next_tasks(
|
||||
self.checkpoint,
|
||||
self.checkpoint_pending_writes,
|
||||
self.nodes,
|
||||
self.channels,
|
||||
self.managed,
|
||||
self.config,
|
||||
self.step,
|
||||
for_execution=True,
|
||||
manager=manager,
|
||||
manager=self.manager,
|
||||
store=self.store,
|
||||
checkpointer=self.checkpointer,
|
||||
)
|
||||
self.to_interrupt = []
|
||||
|
||||
# produce debug output
|
||||
if self._checkpointer_put_after_previous is not None:
|
||||
@@ -381,36 +469,16 @@ class PregelLoop(LoopProtocol):
|
||||
|
||||
# if there are pending writes from a previous loop, apply them
|
||||
if self.skip_done_tasks and self.checkpoint_pending_writes:
|
||||
for tid, k, v in self.checkpoint_pending_writes:
|
||||
if k in (ERROR, INTERRUPT):
|
||||
continue
|
||||
if task := self.tasks.get(tid):
|
||||
if k == SCHEDULED:
|
||||
if v == max(
|
||||
self.checkpoint["versions_seen"]
|
||||
.get(INTERRUPT, {})
|
||||
.values(),
|
||||
default=None,
|
||||
):
|
||||
self.tasks[tid] = task._replace(scheduled=True)
|
||||
else:
|
||||
task.writes.append((k, v))
|
||||
# print output for any tasks we applied previous writes to
|
||||
for task in self.tasks.values():
|
||||
if task.writes:
|
||||
self._output_writes(task.id, task.writes, cached=True)
|
||||
self._match_writes(self.tasks)
|
||||
|
||||
# if all tasks have finished, re-tick
|
||||
if all(task.writes for task in self.tasks.values()):
|
||||
return self.tick(
|
||||
input_keys=input_keys,
|
||||
interrupt_after=interrupt_after,
|
||||
interrupt_before=interrupt_before,
|
||||
manager=manager,
|
||||
)
|
||||
return self.tick(input_keys=input_keys)
|
||||
|
||||
# before execution, check if we should interrupt
|
||||
if should_interrupt(self.checkpoint, interrupt_before, self.tasks.values()):
|
||||
if should_interrupt(
|
||||
self.checkpoint, self.interrupt_before, self.tasks.values()
|
||||
):
|
||||
self.status = "interrupt_before"
|
||||
raise GraphInterrupt()
|
||||
|
||||
@@ -421,10 +489,29 @@ class PregelLoop(LoopProtocol):
|
||||
if self.debug:
|
||||
print_step_tasks(self.step, list(self.tasks.values()))
|
||||
|
||||
# print output for any tasks we applied previous writes to
|
||||
for task in self.tasks.values():
|
||||
if task.writes:
|
||||
self._output_writes(task.id, task.writes, cached=True)
|
||||
|
||||
return True
|
||||
|
||||
# private
|
||||
|
||||
def _match_writes(self, tasks: Mapping[str, PregelExecutableTask]) -> None:
|
||||
for tid, k, v in self.checkpoint_pending_writes:
|
||||
if k in (ERROR, INTERRUPT, RESUME):
|
||||
continue
|
||||
if task := tasks.get(tid):
|
||||
if k == SCHEDULED:
|
||||
if v == max(
|
||||
self.checkpoint["versions_seen"].get(INTERRUPT, {}).values(),
|
||||
default=None,
|
||||
):
|
||||
self.tasks[tid] = task._replace(scheduled=True)
|
||||
else:
|
||||
task.writes.append((k, v))
|
||||
|
||||
def _first(self, *, input_keys: Union[str, Sequence[str]]) -> None:
|
||||
# resuming from previous checkpoint requires
|
||||
# - finding a previous checkpoint
|
||||
@@ -445,8 +532,20 @@ class PregelLoop(LoopProtocol):
|
||||
self._emit(
|
||||
"values", map_output_values, self.output_keys, True, self.channels
|
||||
)
|
||||
# map command to writes
|
||||
elif isinstance(self.input, Command):
|
||||
writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
|
||||
# group writes by task ID
|
||||
for tid, c, v in map_command(self.input):
|
||||
writes[tid].append((c, v))
|
||||
if not writes:
|
||||
raise EmptyInputError("Received empty Command input")
|
||||
# save writes
|
||||
for tid, ws in writes.items():
|
||||
self.put_writes(tid, ws)
|
||||
# map inputs to channel updates
|
||||
elif input_writes := deque(map_input(input_keys, self.input)):
|
||||
# TODO shouldn't these writes be passed to put_writes too?
|
||||
# check if we should delegate (used by subgraphs in distributed mode)
|
||||
if self.config[CONF].get(CONFIG_KEY_DELEGATE):
|
||||
raise GraphDelegate(
|
||||
@@ -460,6 +559,7 @@ class PregelLoop(LoopProtocol):
|
||||
# discard any unfinished tasks from previous checkpoint
|
||||
discard_tasks = prepare_next_tasks(
|
||||
self.checkpoint,
|
||||
self.checkpoint_pending_writes,
|
||||
self.nodes,
|
||||
self.channels,
|
||||
self.managed,
|
||||
@@ -474,7 +574,10 @@ class PregelLoop(LoopProtocol):
|
||||
mv_writes = apply_writes(
|
||||
self.checkpoint,
|
||||
self.channels,
|
||||
[*discard_tasks.values(), PregelTaskWrites(INPUT, input_writes, [])],
|
||||
[
|
||||
*discard_tasks.values(),
|
||||
PregelTaskWrites((), INPUT, input_writes, []),
|
||||
],
|
||||
self.checkpointer_get_next_version,
|
||||
)
|
||||
assert not mv_writes, "Can't write to SharedValues in graph input"
|
||||
@@ -491,6 +594,8 @@ class PregelLoop(LoopProtocol):
|
||||
)
|
||||
|
||||
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
|
||||
for k, v in self.config["metadata"].items():
|
||||
metadata.setdefault(k, v) # type: ignore
|
||||
# assign step and parents
|
||||
metadata["step"] = self.step
|
||||
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
@@ -568,11 +673,33 @@ class PregelLoop(LoopProtocol):
|
||||
# save final output
|
||||
self.output = read_channels(self.channels, self.output_keys)
|
||||
if suppress:
|
||||
# suppress interrupt
|
||||
# emit one last "values" event, with pending writes applied
|
||||
if (
|
||||
hasattr(self, "tasks")
|
||||
and self.checkpoint_pending_writes
|
||||
and any(task.writes for task in self.tasks.values())
|
||||
):
|
||||
mv_writes = apply_writes(
|
||||
self.checkpoint,
|
||||
self.channels,
|
||||
self.tasks.values(),
|
||||
self.checkpointer_get_next_version,
|
||||
)
|
||||
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,
|
||||
)
|
||||
# emit INTERRUPT event
|
||||
self._emit(
|
||||
"updates",
|
||||
lambda: iter([{INTERRUPT: cast(GraphInterrupt, exc_value).args[0]}]),
|
||||
)
|
||||
# suppress interrupt
|
||||
return True
|
||||
|
||||
def _emit(
|
||||
@@ -626,6 +753,9 @@ class SyncPregelLoop(PregelLoop, ContextManager):
|
||||
checkpointer: Optional[BaseCheckpointSaver],
|
||||
nodes: Mapping[str, PregelNode],
|
||||
specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]],
|
||||
manager: Union[None, AsyncParentRunManager, ParentRunManager] = None,
|
||||
interrupt_after: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
interrupt_before: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
output_keys: Union[str, Sequence[str]] = EMPTY_SEQ,
|
||||
stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ,
|
||||
check_subgraphs: bool = True,
|
||||
@@ -641,7 +771,10 @@ class SyncPregelLoop(PregelLoop, ContextManager):
|
||||
specs=specs,
|
||||
output_keys=output_keys,
|
||||
stream_keys=stream_keys,
|
||||
interrupt_after=interrupt_after,
|
||||
interrupt_before=interrupt_before,
|
||||
check_subgraphs=check_subgraphs,
|
||||
manager=manager,
|
||||
debug=debug,
|
||||
)
|
||||
self.stack = ExitStack()
|
||||
@@ -752,6 +885,9 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
|
||||
checkpointer: Optional[BaseCheckpointSaver],
|
||||
nodes: Mapping[str, PregelNode],
|
||||
specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]],
|
||||
interrupt_after: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
interrupt_before: Union[All, Sequence[str]] = EMPTY_SEQ,
|
||||
manager: Union[None, AsyncParentRunManager, ParentRunManager] = None,
|
||||
output_keys: Union[str, Sequence[str]] = EMPTY_SEQ,
|
||||
stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ,
|
||||
check_subgraphs: bool = True,
|
||||
@@ -767,7 +903,10 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
|
||||
specs=specs,
|
||||
output_keys=output_keys,
|
||||
stream_keys=stream_keys,
|
||||
interrupt_after=interrupt_after,
|
||||
interrupt_before=interrupt_before,
|
||||
check_subgraphs=check_subgraphs,
|
||||
manager=manager,
|
||||
debug=debug,
|
||||
)
|
||||
self.stack = AsyncExitStack()
|
||||
|
||||
@@ -18,7 +18,7 @@ from langchain_core.outputs import ChatGenerationChunk, LLMResult
|
||||
from langchain_core.tracers._streaming import T, _StreamingCallbackHandler
|
||||
|
||||
from langgraph.constants import NS_SEP, TAG_HIDDEN, TAG_NOSTREAM
|
||||
from langgraph.pregel.loop import StreamChunk
|
||||
from langgraph.types import StreamChunk
|
||||
|
||||
Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user