mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
Compare commits
280
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
784821705b | ||
|
|
65172c2a43 | ||
|
|
1130c3accb | ||
|
|
ee8653d1c5 | ||
|
|
12486d977a | ||
|
|
c87f9ab6b1 | ||
|
|
855a3d21ff | ||
|
|
d767af421b | ||
|
|
07ac016e60 | ||
|
|
4576a259dd | ||
|
|
53ec7c41b2 | ||
|
|
769f6a1925 | ||
|
|
62a36befd5 | ||
|
|
dfaff2511b | ||
|
|
1d9a0d1e4e | ||
|
|
35c7eb18ee | ||
|
|
dc09b13400 | ||
|
|
b2d8acffc4 | ||
|
|
1031e54860 | ||
|
|
7ac365ea84 | ||
|
|
5144b8f374 | ||
|
|
4b1b3cecb4 | ||
|
|
c6a953c02a | ||
|
|
16b955dee2 | ||
|
|
877124f7df | ||
|
|
d3a4865c0e | ||
|
|
a3761ac522 | ||
|
|
376c58ff3b | ||
|
|
58b99c899e | ||
|
|
2ee279a977 | ||
|
|
f04ce5d1ee | ||
|
|
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 | ||
|
|
58d7eb9b17 | ||
|
|
44ff5f1a5a | ||
|
|
46f25cf926 | ||
|
|
e1467c27cb | ||
|
|
5deedfe548 | ||
|
|
30937d0406 | ||
|
|
6eb8130419 | ||
|
|
8455771eb9 | ||
|
|
39dcafcdea | ||
|
|
7854a19dac | ||
|
|
18cea39090 | ||
|
|
46ffa0e8d4 | ||
|
|
bf3098a075 | ||
|
|
cdd7899564 | ||
|
|
eeebd44a87 | ||
|
|
805f437534 | ||
|
|
782b9a7903 | ||
|
|
ecb584cbe0 | ||
|
|
1e3953d1e0 | ||
|
|
509291fc1a | ||
|
|
dbb1958be5 | ||
|
|
d261d43b39 | ||
|
|
6986a50711 | ||
|
|
e9fe3e8bd9 | ||
|
|
bdca2f590a | ||
|
|
f4801ae9ae | ||
|
|
47ea264b46 | ||
|
|
84e023e10b | ||
|
|
52f0953786 | ||
|
|
d9743f684e | ||
|
|
4aebb461e9 | ||
|
|
c2cc1e2e5e | ||
|
|
66cf2e6779 | ||
|
|
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
@@ -1,216 +0,0 @@
|
||||
# API Concepts
|
||||
|
||||
This page describes the high-level concepts of the LangGraph Cloud API. The conceptual guide of LangGraph (Python library) is [here](../../concepts/high_level.md).
|
||||
|
||||
## Data Models
|
||||
|
||||
The LangGraph Cloud API consists of a few core data models: [Assistants](#assistants), [Threads](#threads), [Runs](#runs), and [Cron Jobs](#cron-jobs).
|
||||
|
||||
### Assistants
|
||||
|
||||
When building agents, it is fairly common to make rapid changes that *do not* alter the graph logic. For example, simply changing prompts or the LLM selection can have significant impacts on the behavior of the agents. Assistants offer an easy way to make and save these types of changes to agent configuration. This can have at least two use-cases:
|
||||
|
||||
* Assistants give developers a quick and easy way to modify and version graph version for experimentation.
|
||||
* Assistants can be modified via LangGraph Studio, offering a no-code way to configure agents (e.g., for business users).
|
||||
|
||||
#### Configuring Assistants
|
||||
|
||||
In practice, an assistant is just an *instance* of a graph with a specific configuration. Because of this, multiple assistants can reference the same graph but can contain different configurations, such as prompts, models, and other graph configuration options. The LangGraph Cloud API provides several endpoints for creating and managing assistants. See the [API reference](../reference/api/api_ref.html#tag/assistantscreate) and [this how-to](../how-tos/configuration_cloud.md) for more details on how to create assistants.
|
||||
|
||||
#### Versioning Assistants
|
||||
|
||||

|
||||
|
||||
Once you've created an assistant, you can save and version it to track changes to the configuration over time. You can think about this at three levels:
|
||||
|
||||
1) The graph lays out the general agent application logic
|
||||
2) The agent configuration options represent parameters that can be changed
|
||||
3) Assistant versions save and track specific settings of the agent configuration options
|
||||
|
||||
For example, if you have an agent that helps for planning trips, you can create a new assistant *for each user* that passes specific user preferences (e.g., desired airline and car service). As each user interacts with their own assistant, assistant versions can be saved that track the specific desires of the user. Read [this how-to](../how-tos/assistant_versioning.md) to learn how you can use assistant versioning through both the [Studio](../how-tos/index.md/#langgraph-studio) and the SDK.
|
||||
|
||||
### Threads
|
||||
|
||||
A thread contains the accumulated state of a group of runs. If a run is executed on a thread, then the [state][state] of the underlying graph of the assistant will be persisted to the thread. A thread's current and historical state can be retrieved. To persist state, a thread must be created prior to executing a run.
|
||||
|
||||
The state of a thread at a particular point in time is called a checkpoint.
|
||||
|
||||
For more on threads and checkpoints, see this section of the [LangGraph conceptual guide](../../concepts/low_level.md#persistence).
|
||||
|
||||
The LangGraph Cloud API provides several endpoints for creating and managing threads and thread state. See the [API reference](../reference/api/api_ref.html#tag/threadscreate) for more details.
|
||||
|
||||
### Runs
|
||||
|
||||
A run is an invocation of an assistant. Each run may have its own input, configuration, and metadata, which may affect execution and output of the underlying graph. A run can optionally be executed on a thread.
|
||||
|
||||
The LangGraph Cloud API provides several endpoints for creating and managing runs. See the [API reference](../reference/api/api_ref.html#tag/runscreate) for more details.
|
||||
|
||||
### Cron Jobs
|
||||
|
||||
It's often useful to run graphs on some schedule. LangGraph Cloud supports cron jobs, which run on a user defined schedule. The user specifies a schedule, an assistant, and some input. After than, on the specified schedule LangGraph cloud will:
|
||||
|
||||
- Create a new thread with the specified assistant
|
||||
- Send the specified input to that thread
|
||||
|
||||
Note that this sends the same input to the thread every time. See the [how-to guide](../how-tos/cron_jobs.md) for creating cron jobs.
|
||||
|
||||
The LangGraph Cloud API provides several endpoints for creating and managing cron jobs. See the [API reference](../reference/api/api_ref.html#tag/runscreate/POST/threads/{thread_id}/runs/crons) for more details.
|
||||
|
||||
## Features
|
||||
|
||||
The LangGraph Cloud API offers several features to support complex agent architectures.
|
||||
|
||||
### Streaming
|
||||
|
||||
Streaming is critical for making LLM applications feel responsive to end users. When creating a streaming run, the streaming mode determines what data is streamed back to the API client. The LangGraph Cloud API supports five streaming modes.
|
||||
|
||||
- `values`: Stream the full state of the graph after each [super-step](https://langchain-ai.github.io/langgraph/concepts/low_level/#graphs) is executed. See the [how-to guide](../how-tos/stream_values.md) for streaming values.
|
||||
- `messages`: Stream complete messages (at the end of node execution) as well as tokens for any messages generated inside a node. This mode is primarily meant for powering chat applications. This is only an option if your graph contains a `messages` key. See the [how-to guide](../how-tos/stream_messages.md) for streaming messages.
|
||||
- `updates`: Streams updates to the state of the graph after each node is executed. See the [how-to guide](../how-tos/stream_updates.md) for streaming updates.
|
||||
- `events`: Stream all events (including the state of the graph) that occur during graph execution. See the [how-to guide](../how-tos/stream_events.md) for streaming events. This can be used to do token-by-token streaming for LLMs.
|
||||
- `debug`: Stream debug events throughout graph execution. See the [how-to guide](../how-tos/stream_debug.md) for streaming debug events.
|
||||
|
||||
You can also specify multiple streaming modes at the same time. See the [how-to guide](../how-tos/stream_multiple.md) for configuring multiple streaming modes at the same time.
|
||||
|
||||
See the [API reference](../reference/api/api_ref.html#tag/runscreate/POST/threads/{thread_id}/runs/stream) for how to create streaming runs.
|
||||
|
||||
Streaming modes `values`, `updates`, and `debug` are very similar to modes available in the LangGraph library - for a deeper conceptual explanation of those, you can see the LangGraph library documentation [here](../../concepts/low_level.md#streaming).
|
||||
|
||||
Streaming mode `events` is the same as using `.astream_events` in the LangGraph library - for a deeper conceptual explanation of this, you can see the LangGraph library documentation [here](../../concepts/low_level.md#streaming).
|
||||
|
||||
#### `mode="messages"`
|
||||
Streaming mode `messages` is a new streaming mode, currently only available in the API. What does this mode enable?
|
||||
|
||||
This mode is focused on streaming back messages. It currently assumes that you have a `messages` key in your graph that is a list of messages. Assuming we have a simple react agent deployed, what does this stream look like?
|
||||
|
||||
All events emitted have two attributes:
|
||||
|
||||
- `event`: This is the name of the event
|
||||
- `data`: This is data associated with the event
|
||||
|
||||
Let's run it on a question that should trigger a tool call:
|
||||
|
||||
```python
|
||||
thread = await client.threads.create()
|
||||
input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}
|
||||
|
||||
events = []
|
||||
async for event in client.runs.stream(
|
||||
thread["thread_id"],
|
||||
assistant_id="agent", # This may need to change depending on the graph you deployed
|
||||
input=input,
|
||||
stream_mode="messages",
|
||||
):
|
||||
print(event.event)
|
||||
```
|
||||
```shell
|
||||
metadata
|
||||
messages/complete
|
||||
messages/metadata
|
||||
messages/partial
|
||||
...
|
||||
messages/partial
|
||||
messages/complete
|
||||
messages/complete
|
||||
messages/metadata
|
||||
messages/partial
|
||||
...
|
||||
messages/partial
|
||||
messages/complete
|
||||
end
|
||||
```
|
||||
|
||||
We first get some `metadata` - this is metadata about the run.
|
||||
|
||||
```python
|
||||
StreamPart(event='metadata', data={'run_id': '1ef657cf-ae55-6f65-97d4-f4ed1dbdabc6'})
|
||||
```
|
||||
|
||||
We then get a `messages/complete` event - this a fully formed message getting emitted. In this case,
|
||||
this was the just the input message we sent in.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/complete', data=[{'content': 'hi!', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': '833c09a3-bb19-46c9-81d9-1e5954ec5f92', 'example': False}])
|
||||
```
|
||||
|
||||
We then get a `messages/metadata` - this is just letting us know that a new message is starting.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/metadata', data={'run-985c0f14-9f43-40d4-a505-4637fc58e333': {'metadata': {'created_by': 'system', 'run_id': '1ef657de-7594-66df-8eb2-31518e4a1ee2', 'graph_id': 'agent', 'thread_id': 'c178eab5-e293-423c-8e7d-1d113ffe7cd9', 'model_name': 'openai', 'assistant_id': 'fe096781-5601-53d2-b2f6-0d3403f7e9ca', 'langgraph_step': 1, 'langgraph_node': 'agent', 'langgraph_triggers': ['start:agent'], 'langgraph_task_idx': 0, 'ls_provider': 'openai', 'ls_model_name': 'gpt-4o', 'ls_model_type': 'chat', 'ls_temperature': 0.0}}})
|
||||
```
|
||||
|
||||
We then get a BUNCH of `messages/partial` events - these are the individual tokens from the LLM! In the case below, we can see the START of a tool call.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/partial', data=[{'content': '', 'additional_kwargs': {'tool_calls': [{'index': 0, 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs', 'function': {'arguments': '', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-985c0f14-9f43-40d4-a505-4637fc58e333', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [{'name': 'tavily_search_results_json', 'args': '', 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs', 'error': None}], 'usage_metadata': None}])
|
||||
```
|
||||
|
||||
After that, we get a `messages/complete` event - this is the AIMessage finishing. It's now a complete tool call:
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/complete', data=[{'content': '', 'additional_kwargs': {'tool_calls': [{'index': 0, 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs', 'function': {'arguments': '{"query":"current weather in San Francisco"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, 'response_metadata': {'finish_reason': 'tool_calls', 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_157b3831f5'}, 'type': 'ai', 'name': None, 'id': 'run-985c0f14-9f43-40d4-a505-4637fc58e333', 'example': False, 'tool_calls': [{'name': 'tavily_search_results_json', 'args': {'query': 'current weather in San Francisco'}, 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs'}], 'invalid_tool_calls': [], 'usage_metadata': None}])
|
||||
```
|
||||
|
||||
After that, we get ANOTHER `messages/complete` event. This is a tool message - our agent has called a tool, gotten a response, and now inserting it into the state in the form of a tool message.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/complete', data=[{'content': '[{"url": "https://www.weatherapi.com/", "content": "{\'location\': {\'name\': \'San Francisco\', \'region\': \'California\', \'country\': \'United States of America\', \'lat\': 37.78, \'lon\': -122.42, \'tz_id\': \'America/Los_Angeles\', \'localtime_epoch\': 1724877689, \'localtime\': \'2024-08-28 13:41\'}, \'current\': {\'last_updated_epoch\': 1724877000, \'last_updated\': \'2024-08-28 13:30\', \'temp_c\': 23.3, \'temp_f\': 73.9, \'is_day\': 1, \'condition\': {\'text\': \'Partly cloudy\', \'icon\': \'//cdn.weatherapi.com/weather/64x64/day/116.png\', \'code\': 1003}, \'wind_mph\': 15.0, \'wind_kph\': 24.1, \'wind_degree\': 310, \'wind_dir\': \'NW\', \'pressure_mb\': 1014.0, \'pressure_in\': 29.93, \'precip_mm\': 0.0, \'precip_in\': 0.0, \'humidity\': 57, \'cloud\': 25, \'feelslike_c\': 25.0, \'feelslike_f\': 77.1, \'windchill_c\': 20.9, \'windchill_f\': 69.6, \'heatindex_c\': 23.3, \'heatindex_f\': 74.0, \'dewpoint_c\': 12.9, \'dewpoint_f\': 55.2, \'vis_km\': 16.0, \'vis_miles\': 9.0, \'uv\': 6.0, \'gust_mph\': 19.5, \'gust_kph\': 31.3}}"}]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'tavily_search_results_json', 'id': '0112eba5-7660-4375-9f24-c7a1d6777b97', 'tool_call_id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs'}])
|
||||
```
|
||||
|
||||
After that, we see the agent doing another LLM call and streaming back a response. We then get an `end` event:
|
||||
|
||||
```python
|
||||
StreamPart(event='end', data=None)
|
||||
```
|
||||
|
||||
And that's it! This is more focused streaming mode specifically focused on streaming back messages. See this [how-to guide](../how-tos/stream_messages.md) for more information.
|
||||
|
||||
|
||||
### Human-in-the-Loop
|
||||
|
||||
There are many occasions where the graph cannot run completely autonomously. For instance, the user might need to input some additional arguments to a function call, or select the next edge for the graph to continue on. In these instances, we need to insert some human in the loop interaction, which you can learn about in the [human in the loop how-tos](../how-tos/index.md#human-in-the-loop).
|
||||
|
||||
### Double Texting
|
||||
|
||||
Many times users might interact with your graph in unintended ways. For instance, a user may send one message and before the graph has finished running send a second message. To solve this issue of "double-texting" (i.e. prompting the graph a second time before the first run has finished), LangGraph has provided four different solutions, all of which are covered in the [Double Texting how-tos](../how-tos/index.md#double-texting). These options are:
|
||||
|
||||
- `reject`: This is the simplest option, this just rejects any follow up runs and does not allow double texting. See the [how-to guide](../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](../how-tos/enqueue_concurrent.md) for configuring the enqueue double text option.
|
||||
- `interrupt`: This option interrupts the current execution but saves all the work done up until that point. It then inserts the user input and continues from there. If you enable this option, your graph should be able to handle weird edge cases that may arise. See the [how-to guide](../how-tos/interrupt_concurrent.md) for configuring the interrupt double text option.
|
||||
- `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. See the [how-to guide](../how-tos/rollback_concurrent.md) for configuring the rollback double text option.
|
||||
|
||||
### Stateless Runs
|
||||
|
||||
All runs use the built-in checkpointer to store checkpoints for runs. However, it can often be useful to just kick off a run without worrying about explicitly creating a thread and without wanting to keep those checkpointers around. Stateless runs allow you to do this by exposing an endpoint that:
|
||||
|
||||
- Takes in user input
|
||||
- Under the hood, creates a thread
|
||||
- Runs the agent but skips all checkpointing steps
|
||||
- Cleans up the thread afterwards
|
||||
|
||||
Stateless runs are still retried as regular retries are per node, while everything still in memory, so doesn't use checkpoints.
|
||||
|
||||
The only difference is in stateless background runs, if the task worker dies halfway (not because the run itself failed, for some external reason) then the whole run will be retried like any background run, but
|
||||
|
||||
- whereas a stateful background run would retry from the last successful checkpoint
|
||||
- a stateless background run would retry from the beginning
|
||||
|
||||
See the [how-to guide](../how-tos/stateless_runs.md) for creating stateless runs.
|
||||
|
||||
### Webhooks
|
||||
|
||||
For all types of runs, langgraph cloud supports completion webhooks. When you create the run you can pass a webhook URL to be called when the completes (successfully or not). This is especially useful for background runs and cron jobs, as the webhook can give you an indication the run has completed and you can perform further actions for your appilcation.
|
||||
|
||||
See this [how-to guide](../how-tos/webhooks.md) to learn about how to use webhooks with LangGraph Cloud.
|
||||
|
||||
## Deployment
|
||||
|
||||
The LangGraph Cloud offers several features to support secure and robost deployments.
|
||||
|
||||
### Authentication
|
||||
|
||||
LangGraph applications deployed to LangGraph Cloud are automatically configured with LangSmith authentication. In order to call the API, a valid <a href="https://docs.smith.langchain.com/how_to_guides/setup/create_account_api_key#api-keys" target="_blank">LangSmith API key</a> is required.
|
||||
|
||||
### Local Testing
|
||||
|
||||
Before deploying your app in production to LangGraph Cloud, you may wish to test out your graph locally in order to ensure that everything is running as expected. Luckily, LangGraph makes this easy for you through use of the LangGraph CLI. Read more in this [how-to guide](../deployment/test_locally.md) or look at the [CLI reference](../reference/cli.md) to learn more.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 257 KiB |
@@ -1,28 +0,0 @@
|
||||
# Cloud Concepts
|
||||
|
||||
This page describes the high-level concepts of the LangGraph Cloud deployment.
|
||||
|
||||
## Deployment
|
||||
|
||||
A deployment is an instance of a LangGraph API. A single deployment can have many [revisions](#revision). When a deployment is created, all of the necessary infrastructure (e.g. database, containers, secrets store) are automatically provisioned. See the [architecture diagram](#architecture) below for more details.
|
||||
|
||||
See the [how-to guide](../deployment/cloud.md#create-new-deployment) for creating a new deployment.
|
||||
|
||||
## 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.
|
||||
|
||||
See the [how-to guide](../deployment/cloud.md#create-new-revision) for creating a new revision.
|
||||
|
||||
## Asynchronous Deployment
|
||||
|
||||
Infrastructure for [deployments](#deployment) and [revisions](#revision) are provisioned and deployed asynchronously. They are not deployed immediately after submission. Currently, deployment can take up to several minutes.
|
||||
|
||||
## Architecture
|
||||
|
||||
!!! warning "Subject to Change"
|
||||
The LangGraph Cloud deployment architecture may change in the future.
|
||||
|
||||
A high-level diagram of a LangGraph Cloud deployment.
|
||||
|
||||

|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB |
@@ -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 \
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Studio FAQs
|
||||
|
||||
## Why is my project failing to start?
|
||||
|
||||
There are a few reasons that your project might fail to start, here are some of the most common ones.
|
||||
|
||||
### Docker issues
|
||||
|
||||
LangGraph Studio requires Docker Desktop version 4.24 or higher. Please make sure you have a version of Docker installed that satisfies that requirement and also make sure you have the Docker Desktop app up and running before trying to use LangGraph Studio. In addition, make sure you have docker-compose updated to version 2.22.0 or higher.
|
||||
|
||||
### Configuration or environment issues
|
||||
|
||||
Another reason your project might fail to start is because your configuration file is defined incorrectly, or you are missing required environment variables.
|
||||
|
||||
## How does interrupt work?
|
||||
|
||||
When you select the `Interrupts` dropdown and select a node to interrupt the graph will pause execution before and after (unless the node goes straight to `END`) that node has run. This means that you will be able to both edit the state before the node is ran and the state after the node has ran. This is intended to allow developers more fine-grained control over the behavior of a node and make it easier to observe how the node is behaving. You will not be able to edit the state after the node has ran if the node is the final node in the graph.
|
||||
|
||||
## How do I reload the app?
|
||||
|
||||
If you would like to reload the app, don't use Command+R as you might normally do. Instead, close and reopen the app for a full refresh.
|
||||
|
||||
## How does automatic rebuilding work?
|
||||
|
||||
One of the key features of LangGraph Studio is that it automatically rebuilds your image when you change the source code. This allows for a super fast development and testing cycle which makes it easy to iterate on your graph. There are two different ways that LangGraph rebuilds your image: either by editing the image or completely rebuilding it.
|
||||
|
||||
### Rebuilds from source code changes
|
||||
|
||||
If you modified the source code only (no configuration or dependency changes!) then the image does not require a full rebuild, and LangGraph Studio will only update the relevant parts. The UI status in the bottom left will switch from `Online` to `Stopping` temporarily while the image gets edited. The logs will be shown as this process is happening, and after the image has been edited the status will change back to `Online` and you will be able to run your graph with the modified code!
|
||||
|
||||
|
||||
### Rebuilds from configuration or dependency changes
|
||||
|
||||
If you edit your graph configuration file (`langgraph.json`) or the dependencies (either `pyproject.toml` or `requirements.txt`) then the entire image will be rebuilt. This will cause the UI to switch away from the graph view and start showing the logs of the new image building process. This can take a minute or two, and once it is done your updated image will be ready to use!
|
||||
|
||||
## Why is my graph taking so long to startup?
|
||||
|
||||
The LangGraph Studio interacts with a local LangGraph API server. To stay aligned with ongoing updates, the LangGraph API requires regular rebuilding. As a result, you may occasionally experience slight delays when starting up your project.
|
||||
|
||||
## Why are extra edges showing up in my graph?
|
||||
|
||||
If you don't define your conditional edges carefully, you might notice extra edges appearing in your graph. This is because without proper definition, LangGraph Studio assumes the conditional edge could access all other nodes. In order for this to not be the case, you need to be explicit about how you define the nodes the conditional edge routes to. There are two ways you can do this:
|
||||
|
||||
### Solution 1: Include a path map
|
||||
|
||||
The first way to solve this is to add path maps to your conditional edges. A path map is just a dictionary or array that maps the possible outputs of your router function with the names of the nodes that each output corresponds to. The path map is passed as the third argument to the `add_conditional_edges` function like so:
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python
|
||||
graph.add_conditional_edges("node_a", routing_function, {True: "node_b", False: "node_c"})
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
```ts
|
||||
graph.addConditionalEdges("node_a", routingFunction, { true: "node_b", false: "node_c" });
|
||||
```
|
||||
|
||||
In this case, the routing function returns either True or False, which map to `node_b` and `node_c` respectively.
|
||||
|
||||
### Solution 2: Update the typing of the router (Python only)
|
||||
|
||||
Instead of passing a path map, you can also be explicit about the typing of your routing function by specifying the nodes it can map to using the `Literal` python definition. Here is an example of how to define a routing function in that way:
|
||||
|
||||
```python
|
||||
def routing_function(state: GraphState) -> Literal["node_b","node_c"]:
|
||||
if state['some_condition'] == True:
|
||||
return "node_b"
|
||||
else:
|
||||
return "node_c"
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# How to version assistants
|
||||
|
||||
In this how-to guide we will walk through how you can create and manage different assistant versions. If you haven't already, you can read [this](../concepts/api.md/#versioning-assistants) conceptual guide to gain a better understanding of what assistant versioning is. This how-to assumes you have a graph that is configurable, which means you have defined a config schema and passed it to your graph as follows:
|
||||
In this how-to guide we will walk through how you can create and manage different assistant versions. If you haven't already, you can read [this](../../concepts/assistants.md#versioning-assistants) conceptual guide to gain a better understanding of what assistant versioning is. This how-to assumes you have a graph that is configurable, which means you have defined a config schema and passed it to your graph as follows:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ You may wish to copy (i.e. "fork") an existing thread in order to keep the exist
|
||||
|
||||
## Setup
|
||||
|
||||
This code assumes you already have a thread to copy. You can read about what a thread is [here](https://langchain-ai.github.io/langgraph/cloud/concepts/api/#threads) and learn how to stream a run on a thread in [these how-to guides](https://langchain-ai.github.io/langgraph/cloud/how-tos/#streaming).
|
||||
This code assumes you already have a thread to copy. You can read about what a thread is [here](../../concepts/langgraph_server.md#threads) and learn how to stream a run on a thread in [these how-to guides](../../how-tos/index.md#streaming_1).
|
||||
|
||||
### SDK initialization
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Enqueue
|
||||
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide](../concepts/api.md#double-texting).
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide](../../concepts/double_texting.md).
|
||||
|
||||
The guide covers the `enqueue` option for double texting, which adds the interruptions to a queue and executes them in the order they are received by the client. Below is a quick example of using the `enqueue` option.
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
hide:
|
||||
- toc
|
||||
---
|
||||
|
||||
# How-to Guides
|
||||
|
||||
Welcome to the LangGraph Cloud how-to guides! These guides provide practical, step-by-step instructions for accomplishing key tasks in LangGraph Cloud.
|
||||
|
||||
## Setup
|
||||
|
||||
LangGraph Cloud gives you best in class observability, testing, and hosting services. Learn how to setup your app for deployment to LangGraph Cloud in these how-to guides
|
||||
|
||||
- [How to set up app for deployment (requirements.txt)](../deployment/setup.md)
|
||||
- [How to set up app for deployment (pyproject.toml)](../deployment/setup_pyproject.md)
|
||||
- [How to set up app for deployment (JavaScript)](../deployment/setup_javascript.md)
|
||||
- [How to customize Dockerfile](../deployment/custom_docker.md)
|
||||
- [How to test locally](../deployment/test_locally.md)
|
||||
|
||||
## Deploy
|
||||
|
||||
Learn how to deploy your app to LangGraph Cloud in these how to guides:
|
||||
|
||||
- [How to deploy to LangGraph cloud](../deployment/cloud.md)
|
||||
- [How to interact with the deployment using RemoteGraph](../../how-tos/use-remote-graph.md)
|
||||
|
||||
|
||||
## Streaming
|
||||
|
||||
Streaming the results of your LLM application is vital for ensuring a good user experience, especially when your graph may call multiple models and take a long time to fully complete a run. Read about how to stream values from your graph in these how to guides:
|
||||
|
||||
- [How to stream values](./stream_values.md)
|
||||
- [How to stream updates](./stream_updates.md)
|
||||
- [How to stream messages](./stream_messages.md)
|
||||
- [How to stream events](./stream_events.md)
|
||||
- [How to stream in debug mode](./stream_debug.md)
|
||||
- [How to stream multiple modes](./stream_multiple.md)
|
||||
|
||||
## 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:
|
||||
|
||||
- [How to use the interrupt option](./interrupt_concurrent.md)
|
||||
- [How to use the rollback option](./rollback_concurrent.md)
|
||||
- [How to use the reject option](./reject_concurrent.md)
|
||||
- [How to use the enqueue option](./enqueue_concurrent.md)
|
||||
|
||||
## 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:
|
||||
|
||||
- [How to add a breakpoint](./human_in_the_loop_breakpoint.md)
|
||||
- [How to wait for user input](./human_in_the_loop_user_input.md)
|
||||
- [How to edit graph state](./human_in_the_loop_edit_state.md)
|
||||
- [How to replay and branch from prior states](./human_in_the_loop_time_travel.md)
|
||||
- [How to review tool calls](./human_in_the_loop_review_tool_calls.md)
|
||||
|
||||
## LangGraph Studio
|
||||
|
||||
LangGraph Studio is a built-in UI for visualizing, testing, and debugging your agents.
|
||||
|
||||
- [How to enter LangGraph Studio](./test_deployment.md)
|
||||
- [How to enter LangGraph Studio for local deployment](./test_local_deployment.md)
|
||||
- [How to test your graph in LangGraph Studio](./invoke_studio.md)
|
||||
- [Interact with threads in LangGraph Studio](./threads_studio.md)
|
||||
|
||||
## Different Types of Runs:
|
||||
|
||||
LangGraph Cloud supports multiple types of runs besides streaming runs.
|
||||
|
||||
- [How to run an agent in the background](./background_run.md)
|
||||
- [How to run multiple agents in the same thread](./same-thread.md)
|
||||
- [How to create cron jobs](./cron_jobs.md)
|
||||
- [How to create stateless runs](./stateless_runs.md)
|
||||
|
||||
## Other
|
||||
|
||||
Other guides that may prove helpful!
|
||||
|
||||
- [How to configure agents](./configuration_cloud.md)
|
||||
- [How to version assistants](./assistant_versioning.md)
|
||||
- [How to convert LangGraph calls to LangGraph cloud calls](./langgraph_to_langgraph_cloud.ipynb)
|
||||
- [How to integrate webhooks](./webhooks.md)
|
||||
- [How to copy threads](./copy_threads.md)
|
||||
- [How to check status of your threads](./check_thread_status.md)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Interrupt
|
||||
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide](../concepts/api.md#double-texting).
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide](../../concepts/double_texting.md).
|
||||
|
||||
The guide covers the `interrupt` option for double texting, which interrupts the prior run of the graph and starts a new one with the double-text. This option does not delete the first run, but rather keeps it in the database but sets its status to `interrupted`. Below is a quick example of using the `interrupt` option.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Reject
|
||||
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide][double-texting].
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide](../../concepts/double_texting.md).
|
||||
|
||||
The guide covers the `reject` option for double texting, which rejects the new run of the graph by throwing an error and continues with the original run until completion. Below is a quick example of using the `reject` option.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Rollback
|
||||
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide][double-texting].
|
||||
This guide assumes knowledge of what double-texting is, which you can learn about in the [double-texting conceptual guide](../../concepts/double_texting.md).
|
||||
|
||||
The guide covers the `rollback` option for double texting, which interrupts the prior run of the graph and starts a new one with the double-text. This option is very similar to the `interrupt` option, but in this case the first run is completely deleted from the database and cannot be restarted. Below is a quick example of using the `rollback` option.
|
||||
|
||||
@@ -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 "{
|
||||
|
||||
@@ -292,131 +292,4 @@ Output:
|
||||
|
||||
|
||||
Receiving new event of type: end...
|
||||
None
|
||||
|
||||
|
||||
## Token-by-Token Streaming
|
||||
|
||||
Token-by-token streaming can be implemented with the `events` streaming mode. The `on_chat_model_stream` event type should be processed to stream LLM responses token-by-token.
|
||||
|
||||
=== "Python"
|
||||
|
||||
```python
|
||||
llm_response = ""
|
||||
|
||||
# stream token-by-token
|
||||
async for chunk in client.runs.stream(
|
||||
thread_id=thread["thread_id"],
|
||||
assistant_id=assistant_id,
|
||||
input=input,
|
||||
stream_mode="events",
|
||||
):
|
||||
if (
|
||||
chunk.event == "events" and
|
||||
chunk.data["event"] == "on_chat_model_stream" and
|
||||
len(chunk.data["data"]["chunk"]["content"]) > 0 and
|
||||
'text' in chunk.data["data"]["chunk"]["content"][0]
|
||||
):
|
||||
llm_response += chunk.data["data"]["chunk"]["content"][0]['text']
|
||||
print(llm_response)
|
||||
```
|
||||
|
||||
=== "Javascript"
|
||||
|
||||
```js
|
||||
const llmResponse = "";
|
||||
// stream events
|
||||
const streamResponse = client.runs.stream(
|
||||
thread["thread_id"],
|
||||
assistantID,
|
||||
{
|
||||
input,
|
||||
streamMode: "events"
|
||||
}
|
||||
);
|
||||
for await (const chunk of streamResponse) {
|
||||
if (chunk.event === "events" && chunk.data.event === "on_chat_model_stream" && chunk.data.chunk.content.length > 0 && 'text' in chunk.data.chunk.content[0]) {
|
||||
llmResponse += chunk.data.data.chunk.content[0].text;
|
||||
console.log(llmResponse);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
=== "CURL"
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{
|
||||
\"assistant_id\": \"agent\",
|
||||
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"What's the weather in sf\"}]},
|
||||
\"stream_mode\": [
|
||||
\"events\"
|
||||
]
|
||||
}" | sed 's/\r$//' | awk '
|
||||
/^event:/ { event = $2 }
|
||||
/^data:/ {
|
||||
json_data = substr($0, index($0, $2))
|
||||
|
||||
if (event == "events") {
|
||||
print json_data
|
||||
}
|
||||
}' | jq -r '
|
||||
select(.event == "on_chat_model_stream") |
|
||||
.data.chunk.content[] | .text // empty
|
||||
' | awk '
|
||||
BEGIN { llm_response="" }
|
||||
$0 != "" && $0 != "null" {
|
||||
llm_response = llm_response $0
|
||||
print llm_response
|
||||
}'
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
The
|
||||
The search
|
||||
The search results provide
|
||||
The search results provide the current weather conditions
|
||||
The search results provide the current weather conditions in San Francisco.
|
||||
The search results provide the current weather conditions in San Francisco. According
|
||||
The search results provide the current weather conditions in San Francisco. According to the data,
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12,
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024,
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C).
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The win
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is bl
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 k
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph).
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70%
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km).
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears to be a nice
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears to be a nice sunny day in San
|
||||
The search results provide the current weather conditions in San Francisco. According to the data, as of 3:19 PM on August 12, 2024, the weather in San Francisco is sunny with a temperature of 60.8°F (16°C). The wind is blowing from the west-southwest at 13.4 mph (21.6 kph). The humidity is 70% and visibility is 6 miles (10 km). Overall, it appears to be a nice sunny day in San Francisco.
|
||||
|
||||
|
||||
None
|
||||
@@ -3,9 +3,7 @@
|
||||
!!! info "Prerequisites"
|
||||
* [Streaming](../../concepts/streaming.md)
|
||||
|
||||
This guide covers how to stream messages from your graph. With `stream_mode="messages"`, messages from any chat model invocations inside your graph nodes will be streamed back.
|
||||
|
||||
Read more about how the `messages` streaming mode works [here](https://langchain-ai.github.io/langgraph/cloud/concepts/api/#modemessages)
|
||||
This guide covers how to stream messages from your graph. With `stream_mode="messages-tuple"`, messages (i.e. individual LLM tokens) from any chat model invocations inside your graph nodes will be streamed back.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -60,7 +58,7 @@ Output:
|
||||
|
||||
## Stream graph in messages mode
|
||||
|
||||
Now we can stream by messages, which will return complete messages (at the end of node execution) as well as tokens for any messages generated inside a node:
|
||||
Now we can stream LLM tokens for any messages generated inside a node in the form of tuples `(message, metadata)`. Metadata contains additional information that can be useful for filtering the streamed outputs to a specific node or LLM.
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -73,7 +71,7 @@ Now we can stream by messages, which will return complete messages (at the end o
|
||||
assistant_id=assistant_id,
|
||||
input=input,
|
||||
config=config,
|
||||
stream_mode="messages",
|
||||
stream_mode="messages-tuple",
|
||||
):
|
||||
print(f"Receiving new event of type: {chunk.event}...")
|
||||
print(chunk.data)
|
||||
@@ -99,7 +97,7 @@ Now we can stream by messages, which will return complete messages (at the end o
|
||||
{
|
||||
input,
|
||||
config,
|
||||
streamMode: "messages"
|
||||
streamMode: "messages-tuple"
|
||||
}
|
||||
);
|
||||
for await (const chunk of streamResponse) {
|
||||
@@ -119,7 +117,7 @@ Now we can stream by messages, which will return complete messages (at the end o
|
||||
\"assistant_id\": \"agent\",
|
||||
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"what's the weather in la\"}]},
|
||||
\"stream_mode\": [
|
||||
\"messages\"
|
||||
\"messages-tuple\"
|
||||
]
|
||||
}" | \
|
||||
sed 's/\r$//' | \
|
||||
@@ -150,117 +148,101 @@ Output:
|
||||
Receiving new event of type: metadata...
|
||||
{"run_id": "1ef971e0-9a84-6154-9047-247b4ce89c4d", "attempt": 1}
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages/metadata...
|
||||
{
|
||||
"run-700157a5-df1a-4829-9e7c-1e07a1d934f7": {
|
||||
"metadata": {
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
Receiving new event of type: messages/partial...
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"type": "AIMessageChunk",
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "tavily_search_results_json",
|
||||
"args": {
|
||||
"query": "weather"
|
||||
"query": "weat"
|
||||
},
|
||||
"id": "toolu_01RJGmVJtTxccoHHixGkGqaC",
|
||||
"type": "tool_call"
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages/partial...
|
||||
[
|
||||
{
|
||||
"type": "ai",
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "tavily_search_results_json",
|
||||
"args": {
|
||||
"query": "weather in "
|
||||
},
|
||||
"id": "toolu_01RJGmVJtTxccoHHixGkGqaC",
|
||||
"id": "toolu_0114XKXdNtHQEa3ozmY1uDdM",
|
||||
"type": "tool_call"
|
||||
}
|
||||
],
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
...
|
||||
|
||||
Receiving new event of type: messages/partial...
|
||||
[
|
||||
},
|
||||
{
|
||||
"type": "ai",
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "tavily_search_results_json",
|
||||
"args": {
|
||||
"query": "weather in san francisco"
|
||||
},
|
||||
"id": "toolu_01RJGmVJtTxccoHHixGkGqaC",
|
||||
"type": "tool_call"
|
||||
}
|
||||
],
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages/metadata...
|
||||
{
|
||||
"aa162b98-433d-4e3c-b204-0d41a6694156": {
|
||||
"metadata": {
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "action",
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages/complete...
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"content": "[{\"url\": \"https://www.weatherapi.com/\", \"content\": \"{'location': {'name': 'San Francisco', 'region': 'California', 'country': 'United States of America', 'lat': 37.775, 'lon': -122.4183, 'tz_id': 'America/Los_Angeles', 'localtime_epoch': 1730334046, 'localtime': '2024-10-30 17:20'}, 'current': {'last_updated_epoch': 1730333700, 'last_updated': '2024-10-30 17:15', 'temp_c': 12.3, 'temp_f': 54.2, 'is_day': 1, 'condition': {'text': 'Partly Cloudy', 'icon': '//cdn.weatherapi.com/weather/64x64/day/116.png', 'code': 1003}, 'wind_mph': 9.6, 'wind_kph': 15.5, 'wind_degree': 238, 'wind_dir': 'WSW', 'pressure_mb': 1021.0, 'pressure_in': 30.15, 'precip_mm': 0.0, 'precip_in': 0.0, 'humidity': 93, 'cloud': 57, 'feelslike_c': 11.2, 'feelslike_f': 52.2, 'windchill_c': 11.2, 'windchill_f': 52.2, 'heatindex_c': 12.3, 'heatindex_f': 54.2, 'dewpoint_c': 11.2, 'dewpoint_f': 52.1, 'vis_km': 10.0, 'vis_miles': 6.0, 'uv': 0.5, 'gust_mph': 12.9, 'gust_kph': 20.8}}\"}]",
|
||||
"type": "AIMessageChunk",
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "tavily_search_results_json",
|
||||
"args": {
|
||||
"query": "her in san "
|
||||
},
|
||||
"id": "toolu_0114XKXdNtHQEa3ozmY1uDdM",
|
||||
"type": "tool_call"
|
||||
}
|
||||
],
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
...
|
||||
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"type": "AIMessageChunk",
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "tavily_search_results_json",
|
||||
"args": {
|
||||
"query": "francisco"
|
||||
},
|
||||
"id": "toolu_0114XKXdNtHQEa3ozmY1uDdM",
|
||||
"type": "tool_call"
|
||||
}
|
||||
],
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
...
|
||||
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"content": "[{\"url\": \"https://www.weatherapi.com/\", \"content\": \"{'location': {'name': 'San Francisco', 'region': 'California', 'country': 'United States of America', 'lat': 37.775, 'lon': -122.4183, 'tz_id': 'America/Los_Angeles', 'localtime_epoch': 1730475777, 'localtime': '2024-11-01 08:42'}, 'current': {'last_updated_epoch': 1730475000, 'last_updated': '2024-11-01 08:30', 'temp_c': 11.1, 'temp_f': 52.0, 'is_day': 1, 'condition': {'text': 'Partly cloudy', 'icon': '//cdn.weatherapi.com/weather/64x64/day/116.png', 'code': 1003}, 'wind_mph': 2.2, 'wind_kph': 3.6, 'wind_degree': 192, 'wind_dir': 'SSW', 'pressure_mb': 1018.0, 'pressure_in': 30.07, 'precip_mm': 0.0, 'precip_in': 0.0, 'humidity': 89, 'cloud': 75, 'feelslike_c': 11.5, 'feelslike_f': 52.6, 'windchill_c': 10.0, 'windchill_f': 50.1, 'heatindex_c': 10.4, 'heatindex_f': 50.7, 'dewpoint_c': 9.1, 'dewpoint_f': 48.5, 'vis_km': 16.0, 'vis_miles': 9.0, 'uv': 3.0, 'gust_mph': 6.7, 'gust_kph': 10.8}}\"}]",
|
||||
"type": "tool",
|
||||
"name": "tavily_search_results_json",
|
||||
"tool_call_id": "toolu_01RJGmVJtTxccoHHixGkGqaC",
|
||||
"tool_call_id": "toolu_0114XKXdNtHQEa3ozmY1uDdM",
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "action",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
...
|
||||
|
||||
Receiving new event of type: messages/metadata...
|
||||
{
|
||||
"run-f92646d2-6b13-4648-90c7-0280766bfaf2": {
|
||||
"metadata": {
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages/partial...
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
@@ -270,41 +252,80 @@ Output:
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"type": "ai",
|
||||
"type": "AIMessageChunk",
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages/partial...
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "\n\nThe search results provide",
|
||||
"text": " results provide",
|
||||
"type": "text",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"type": "ai",
|
||||
"type": "AIMessageChunk",
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
...
|
||||
|
||||
Receiving new event of type: messages/partial...
|
||||
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "\n\nThe search results provide the current weather conditions in San Francisco. According to the data, as of 5:20pm on October 30, 2024, the weather in San Francisco is partly cloudy with a temperature of 54\\u00b0F (12\\u00b0C). The wind is blowing from the west-southwest at around 10 mph (15 km/h). The humidity is high at 93% and visibility is 6 miles (10 km). Overall, it seems to be a cool, partly cloudy day with moderate winds in San Francisco.",
|
||||
"text": " the current weather conditions",
|
||||
"type": "text",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"type": "ai",
|
||||
"type": "AIMessageChunk",
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
|
||||
Receiving new event of type: messages...
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": " in San Francisco.",
|
||||
"type": "text",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
"type": "AIMessageChunk",
|
||||
...
|
||||
},
|
||||
{
|
||||
"graph_id": "agent",
|
||||
"langgraph_node": "agent",
|
||||
...
|
||||
}
|
||||
]
|
||||
|
||||
...
|
||||
@@ -76,7 +76,9 @@ Output:
|
||||
|
||||
## Use graph with a webhook
|
||||
|
||||
Now we can invoke a run with a webhook:
|
||||
To invoke a run with a webhook, we specify the `webhook` parameter with the desired endpoint when creating a run. Webhook requests are triggered by the end of a run.
|
||||
|
||||
For example, if we can receive requests at `https://my-server.app/my-webhook-endpoint`, we can pass this to `stream`:
|
||||
|
||||
=== "Python"
|
||||
|
||||
@@ -89,7 +91,7 @@ Now we can invoke a run with a webhook:
|
||||
assistant_id=assistant_id,
|
||||
input=input,
|
||||
stream_mode="events",
|
||||
webhook="your-webhook"
|
||||
webhook="https://my-server.app/my-webhook-endpoint"
|
||||
):
|
||||
# Do something with the stream output
|
||||
pass
|
||||
@@ -107,7 +109,7 @@ Now we can invoke a run with a webhook:
|
||||
assistantID,
|
||||
{
|
||||
input: input,
|
||||
webhook: "your-webhook"
|
||||
webhook: "https://my-server.app/my-webhook-endpoint"
|
||||
}
|
||||
);
|
||||
for await (const chunk of streamResponse) {
|
||||
@@ -124,8 +126,17 @@ Now we can invoke a run with a webhook:
|
||||
--data '{
|
||||
"assistant_id": <ASSISTANT_ID>,
|
||||
"input" : {"messages":[{"role": "user", "content": "Hello!"}]},
|
||||
"webhook": <YOUR_WEBHOOK_URL>
|
||||
"webhook": "https://my-server.app/my-webhook-endpoint"
|
||||
}'
|
||||
```
|
||||
|
||||
And that's it! Now you can trigger your custom webhooks whenever you want in your LangGraph applications!
|
||||
The schema for the payload sent to `my-webhook-endpoint` is that of a [run](../../concepts/langgraph_server.md/#runs). See [API Reference](https://langchain-ai.github.io/langgraph/cloud/reference/api/api_ref.html#model/run) for more detail. Note that the run input, configuration, etc. are included in the `kwargs` field.
|
||||
|
||||
### Signing webhook requests
|
||||
|
||||
To sign the webhook requests, we can specify a token parameter in the webhook URL, e.g.,
|
||||
```
|
||||
https://my-server.app/my-webhook-endpoint?token=...
|
||||
```
|
||||
|
||||
The server should then extract the token from the request's parameters and validate it before processing the payload.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# LangGraph Cloud (beta)
|
||||
|
||||
!!! tip
|
||||
- LangGraph is an MIT-licensed open-source library, which we are committed to maintaining and growing for the community.
|
||||
- LangGraph Cloud is an optional managed hosting service for LangGraph, which provides additional features geared towards production deployments.
|
||||
- We are actively contributing improvements back to LangGraph informed by our work on LangGraph Cloud.
|
||||
- You can always deploy LangGraph applications on your own infrastructure using the open-source LangGraph project.
|
||||
|
||||
!!! warning "Under Construction"
|
||||
LangGraph Cloud documentation is under construction. Contents may change until general availability.
|
||||
|
||||
|
||||
<video controls preload="auto" allowfullscreen="true" poster="how-tos/img/studio_forks_poster.png">
|
||||
<source src="how-tos/img/studio_forks.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
LangGraph Cloud is a managed service for deploying and hosting LangGraph applications. Deploying applications with LangGraph Cloud shortens the time-to-market for developers. With one click, deploy a production-ready API with built-in persistence for your LangGraph application. LangGraph Cloud APIs are horizontally scalable and deployed with durable storage.
|
||||
|
||||
The LangGraph Cloud API exposes functionality of your LangGraph application through [Assistants](./concepts/api.md#assistants). An assistant abstracts the cognitive architecture of your graph. Invoke an assistant by calling the pre-built [API endpoints](./reference/api/api_ref.md).
|
||||
|
||||
LangGraph Cloud is seamlessly integrated with [LangSmith](https://www.langchain.com/langsmith) and is accessible from within the LangSmith UI.
|
||||
|
||||
LangGraph Cloud applications can be tested and debugged using the [LangGraph Studio Desktop](https://github.com/langchain-ai/langgraph-studio).
|
||||
|
||||
## Key Features
|
||||
|
||||
The LangGraph Cloud API supports key LangGraph features in addition to new functionality for enabling complex, agentic workflows.
|
||||
|
||||
- **Assistants and Threads**: Assistants abstract the cognitive architecture of graphs and threads track the state/history of graphs.
|
||||
- **Streaming**: API support for [LangGraph streaming modes](../concepts/low_level.md#streaming) including setting multiple streaming modes at the same time.
|
||||
- **Human-in-the-Loop**: API support for [LangGraph human-in-the-loop features](../concepts/agentic_concepts.md#human-in-the-loop).
|
||||
- **Double Texting**: Configure how assistants respond when new input is received while processing a previous input. Interrupt, rollback, reject, or enqueue.
|
||||
- **Background Runs/Cron Jobs**: A built-in task queue enables background runs and scheduled cron jobs.
|
||||
- **Stateless Runs**: For simpler use cases, invoke an assistant without needing to create a thread.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Tutorials](./quick_start.md): Learn to build and deploy applications for LangGraph Cloud.
|
||||
- [How-to Guides](./how-tos/index.md): Learn how to set up a LangGraph application for deployment and implement features of the LangGraph Cloud API such as streaming tokens, configuring double texting, and creating cron jobs. Go here if you want to copy and run a specific code snippet.
|
||||
- [Conceptual Guides](./concepts/api.md): In-depth explanations of the core data models (e.g. assistants), key features of the LangGraph Cloud API (e.g. double texting), and the architecture of a LangGraph Cloud deployment.
|
||||
- [Reference](./reference/api/api_ref.md): References for the LangGraph Cloud API, the corresponding Python and JS/TS SDKs, the LangGraph CLI, and deployment environment variables.
|
||||
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Open Assistants API Specification</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<script id="api-reference" data-url="./open_agent_api.json"></script>
|
||||
<script>
|
||||
var configuration = {}
|
||||
document.getElementById('api-reference').dataset.configuration =
|
||||
JSON.stringify(configuration)
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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": {
|
||||
@@ -304,10 +324,19 @@
|
||||
"description": "The ID of the assistant.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Assistant ID",
|
||||
"description": "The ID of the assistant."
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Assistant ID",
|
||||
"description": "The ID of the assistant."
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"title": "Graph ID",
|
||||
"description": "The ID of the graph."
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "assistant_id",
|
||||
"in": "path"
|
||||
@@ -1528,8 +1557,11 @@
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1876,8 +1908,11 @@
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2114,8 +2149,11 @@
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "The server will send a stream of events in SSE format.\n\n**Example event**:\n\nid: 1\n\nevent: message\n\ndata: {}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2839,9 +2877,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": [
|
||||
@@ -3142,6 +3189,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": {
|
||||
@@ -3167,13 +3274,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"
|
||||
@@ -3376,13 +3489,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.
|
||||
|
||||
@@ -25,7 +25,7 @@ Once you've created an assistant, you can save and version it to track changes t
|
||||
2) The agent configuration options represent parameters that can be changed
|
||||
3) Assistant versions save and track specific settings of the agent configuration options
|
||||
|
||||
For example, let's imagine you have a general writing agent. You have created a general graph architecture that works well for writing. However, there are different types of writing, e.g. blogs vs tweets. In order to get the best performance on each use case, you need to make some minor changes to the models and prompts used. In this setup, you could create an assistant for each use case - one for blog writing and one for tweeting. These would share the same graph structure, but they may use different models and different prompts. Read [this how-to](../cloud/how-tos/assistant_versioning.md) to learn how you can use assistant versioning through both the [Studio](../cloud/how-tos/index.md/#langgraph-studio) and the SDK.
|
||||
For example, let's imagine you have a general writing agent. You have created a general graph architecture that works well for writing. However, there are different types of writing, e.g. blogs vs tweets. In order to get the best performance on each use case, you need to make some minor changes to the models and prompts used. In this setup, you could create an assistant for each use case - one for blog writing and one for tweeting. These would share the same graph structure, but they may use different models and different prompts. Read [this how-to](../cloud/how-tos/assistant_versioning.md) to learn how you can use assistant versioning through both the [Studio](../concepts/langgraph_studio.md) and the SDK.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -55,4 +55,4 @@ Once you've built a graph, you often want to test and debug it. [LangGraph Studi
|
||||
|
||||
## Deployment
|
||||
|
||||
Once you have confidence in your LangGraph application, many developers want an easy path to deployment. [LangGraph Cloud](../cloud/index.md) is an opinionated, simple way to deploy LangGraph objects from the LangChain team. Of course, you can also use services like [FastAPI](https://fastapi.tiangolo.com/) and call your graph from inside the FastAPI server as you see fit.
|
||||
Once you have confidence in your LangGraph application, many developers want an easy path to deployment. [LangGraph Platform](../concepts/index.md#langgraph-platform) offers a range of options for deploying LangGraph graphs.
|
||||
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).
|
||||
|
||||
@@ -153,7 +153,7 @@ guide for that [here](../how-tos/streaming-tokens.ipynb).
|
||||
Streaming is critical for making LLM applications feel responsive to end users. When creating a streaming run, the streaming mode determines what data is streamed back to the API client. LangGraph Platform supports five streaming modes:
|
||||
|
||||
- `values`: Stream the full state of the graph after each [super-step](https://langchain-ai.github.io/langgraph/concepts/low_level/#graphs) is executed. See the [how-to guide](../cloud/how-tos/stream_values.md) for streaming values.
|
||||
- `messages`: Stream complete messages (at the end of node execution) as well as tokens for any messages generated inside a node. This mode is primarily meant for powering chat applications. This is only an option if your graph contains a `messages` key. See the [how-to guide](../cloud/how-tos/stream_messages.md) for streaming messages.
|
||||
- `messages-tuple`: Stream LLM tokens for any messages generated inside a node. This mode is primarily meant for powering chat applications. See the [how-to guide](../cloud/how-tos/stream_messages.md) for streaming messages.
|
||||
- `updates`: Streams updates to the state of the graph after each node is executed. See the [how-to guide](../cloud/how-tos/stream_updates.md) for streaming updates.
|
||||
- `events`: Stream all events (including the state of the graph) that occur during graph execution. See the [how-to guide](../cloud/how-tos/stream_events.md) for streaming events. This can be used to do token-by-token streaming for LLMs.
|
||||
- `debug`: Stream debug events throughout graph execution. See the [how-to guide](../cloud/how-tos/stream_debug.md) for streaming debug events.
|
||||
@@ -162,90 +162,11 @@ You can also specify multiple streaming modes at the same time. See the [how-to
|
||||
|
||||
See the [API reference](../cloud/reference/api/api_ref.html#tag/threads-runs/POST/threads/{thread_id}/runs/stream) for how to create streaming runs.
|
||||
|
||||
Streaming modes `values`, `updates`, and `debug` are very similar to modes available in the LangGraph library - for a deeper conceptual explanation of those, you can see the [previous section](#streaming-graph-outputs-stream-and-astream).
|
||||
Streaming modes `values`, `updates`, `messages-tuple` and `debug` are very similar to modes available in the LangGraph library - for a deeper conceptual explanation of those, you can see the [previous section](#streaming-graph-outputs-stream-and-astream).
|
||||
|
||||
Streaming mode `events` is the same as using `.astream_events` in the LangGraph library - for a deeper conceptual explanation of this, you can see the [previous section](#streaming-graph-outputs-stream-and-astream).
|
||||
|
||||
### `stream_mode="messages"`
|
||||
|
||||
Streaming mode `messages` is for streaming back messages from the LLM. Assuming we have a simple [ReAct](./agentic_concepts.md#react-implementation)-style agent deployed, what does this stream look like?
|
||||
|
||||
All events emitted have two attributes:
|
||||
|
||||
- `event`: This is the name of the event
|
||||
- `data`: This is data associated with the event
|
||||
|
||||
!!! note
|
||||
Streaming mode `messages` is different from the one in the LangGraph library:
|
||||
|
||||
- LangGraph Server streams event objects with messages in the `data` field, while LangGraph library streams tuples (`AIMessageChunk`, metadata).
|
||||
- In LangGraph Server, metadata is streamed only once per message (`messages/metadata`), before the individual tokens are streamed (`messages/partial`), while in LangGraph library it's streamed with every `AIMessageChunk` (for each LLM token).
|
||||
- LangGraph Server also streams additional events (`metadata`, `messages/complete`, see below for more details).
|
||||
|
||||
Let's run it on a question that should trigger a tool call:
|
||||
|
||||
```python
|
||||
thread = await client.threads.create()
|
||||
input = {"messages": [{"role": "user", "content": "what's the weather in sf?"}]}
|
||||
|
||||
events = []
|
||||
async for event in client.runs.stream(
|
||||
thread["thread_id"],
|
||||
assistant_id="agent", # This may need to change depending on the graph you deployed
|
||||
input=input,
|
||||
stream_mode="messages",
|
||||
):
|
||||
print(event.event)
|
||||
```
|
||||
```shell
|
||||
metadata
|
||||
messages/metadata
|
||||
messages/partial
|
||||
...
|
||||
messages/partial
|
||||
messages/metadata
|
||||
messages/complete
|
||||
messages/metadata
|
||||
messages/partial
|
||||
...
|
||||
messages/partial
|
||||
end
|
||||
```
|
||||
|
||||
We first get some `metadata` - this is metadata about the run.
|
||||
|
||||
```python
|
||||
StreamPart(event='metadata', data={'run_id': '1ef657cf-ae55-6f65-97d4-f4ed1dbdabc6'})
|
||||
```
|
||||
|
||||
We then get a `messages/metadata` - this is letting us know that a new message is starting and provides additional information about the LLM as well as the node where the LLM is invoked.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/metadata', data={'run-985c0f14-9f43-40d4-a505-4637fc58e333': {'metadata': {'created_by': 'system', 'run_id': '1ef657de-7594-66df-8eb2-31518e4a1ee2', 'graph_id': 'agent', 'thread_id': 'c178eab5-e293-423c-8e7d-1d113ffe7cd9', 'model_name': 'openai', 'assistant_id': 'fe096781-5601-53d2-b2f6-0d3403f7e9ca', 'langgraph_step': 1, 'langgraph_node': 'agent', 'langgraph_triggers': ['start:agent'], 'langgraph_task_idx': 0, 'ls_provider': 'openai', 'ls_model_name': 'gpt-4o', 'ls_model_type': 'chat', 'ls_temperature': 0.0}}})
|
||||
```
|
||||
|
||||
We then get a BUNCH of `messages/partial` events - these are the individual tokens from the LLM! In the case below, we can see the START of a tool call.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/partial', data=[{'content': '', 'additional_kwargs': {'tool_calls': [{'index': 0, 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs', 'function': {'arguments': '', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-985c0f14-9f43-40d4-a505-4637fc58e333', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [{'name': 'tavily_search_results_json', 'args': '', 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs', 'error': None}], 'usage_metadata': None}])
|
||||
```
|
||||
|
||||
The last `messages/partial` event for a given message will contain all of the tokens streamed for that message. In our case, it is now a complete tool call:
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/partial', data=[{'content': '', 'additional_kwargs': {'tool_calls': [{'index': 0, 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs', 'function': {'arguments': '{"query":"current weather in San Francisco"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, 'response_metadata': {'finish_reason': 'tool_calls', 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_157b3831f5'}, 'type': 'ai', 'name': None, 'id': 'run-985c0f14-9f43-40d4-a505-4637fc58e333', 'example': False, 'tool_calls': [{'name': 'tavily_search_results_json', 'args': {'query': 'current weather in San Francisco'}, 'id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs'}], 'invalid_tool_calls': [], 'usage_metadata': None}])
|
||||
```
|
||||
|
||||
After that, we get another `messages/metadata`, now followed by a `messages/complete` event. This event is emitted for a tool message - our agent has called a tool, gotten a response, and now inserting it into the state in the form of a tool message.
|
||||
|
||||
```python
|
||||
StreamPart(event='messages/complete', data=[{'content': '[{"url": "https://www.weatherapi.com/", "content": "{\'location\': {\'name\': \'San Francisco\', \'region\': \'California\', \'country\': \'United States of America\', \'lat\': 37.78, \'lon\': -122.42, \'tz_id\': \'America/Los_Angeles\', \'localtime_epoch\': 1724877689, \'localtime\': \'2024-08-28 13:41\'}, \'current\': {\'last_updated_epoch\': 1724877000, \'last_updated\': \'2024-08-28 13:30\', \'temp_c\': 23.3, \'temp_f\': 73.9, \'is_day\': 1, \'condition\': {\'text\': \'Partly cloudy\', \'icon\': \'//cdn.weatherapi.com/weather/64x64/day/116.png\', \'code\': 1003}, \'wind_mph\': 15.0, \'wind_kph\': 24.1, \'wind_degree\': 310, \'wind_dir\': \'NW\', \'pressure_mb\': 1014.0, \'pressure_in\': 29.93, \'precip_mm\': 0.0, \'precip_in\': 0.0, \'humidity\': 57, \'cloud\': 25, \'feelslike_c\': 25.0, \'feelslike_f\': 77.1, \'windchill_c\': 20.9, \'windchill_f\': 69.6, \'heatindex_c\': 23.3, \'heatindex_f\': 74.0, \'dewpoint_c\': 12.9, \'dewpoint_f\': 55.2, \'vis_km\': 16.0, \'vis_miles\': 9.0, \'uv\': 6.0, \'gust_mph\': 19.5, \'gust_kph\': 31.3}}"}]', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'tool', 'name': 'tavily_search_results_json', 'id': '0112eba5-7660-4375-9f24-c7a1d6777b97', 'tool_call_id': 'call_w8Hr8dHGuZCPgRfd5FqRBArs'}])
|
||||
```
|
||||
|
||||
After that, we see the agent doing another LLM call and streaming back a response. We then get an `end` event:
|
||||
|
||||
```python
|
||||
StreamPart(event='end', data=None)
|
||||
```
|
||||
|
||||
And that's it! This is more focused streaming mode specifically focused on streaming back messages. See this [how-to guide](../cloud/how-tos/stream_messages.md) for more information.
|
||||
- `data`: This is data associated with the event
|
||||
@@ -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)
|
||||
@@ -220,11 +222,12 @@ LangGraph Studio is a built-in UI for visualizing, testing, and debugging your a
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
The [Error Reference](../troubleshooting/errors/index.md) page contains guides around resolving common errors you may find while building with LangGraph. Errors referenced below will have an `lc_error_code` property corresponding to one of the below codes when they are thrown in code.
|
||||
These are the guides for resolving common errors you may find while building with LangGraph. Errors referenced below will have an `lc_error_code` property corresponding to one of the below codes when they are thrown in code.
|
||||
|
||||
- [GRAPH_RECURSION_LIMIT](../troubleshooting/errors/GRAPH_RECURSION_LIMIT.md)
|
||||
- [INVALID_CONCURRENT_GRAPH_UPDATE](../troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md)
|
||||
- [INVALID_GRAPH_NODE_RETURN_VALUE](../troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
|
||||
- [MULTIPLE_SUBGRAPHS](../troubleshooting/errors/MULTIPLE_SUBGRAPHS.md)
|
||||
- [INVALID_CHAT_HISTORY](../troubleshooting/errors/INVALID_CHAT_HISTORY.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
|
||||
@@ -217,6 +217,12 @@ Since the `RemoteGraph` behaves the same way as a regular `CompiledGraph`, it ca
|
||||
"messages": [{"role": "user", "content": "what's the weather in sf"}]
|
||||
})
|
||||
print(result)
|
||||
|
||||
# stream outputs from both the parent graph and subgraph
|
||||
for chunk in graph.stream({
|
||||
"messages": [{"role": "user", "content": "what's the weather in sf"}]
|
||||
}, subgraphs=True):
|
||||
print(chunk)
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
@@ -240,4 +246,11 @@ Since the `RemoteGraph` behaves the same way as a regular `CompiledGraph`, it ca
|
||||
messages: [{ role: "user", content: "what's the weather in sf" }]
|
||||
});
|
||||
console.log(result);
|
||||
|
||||
// stream outputs from both the parent graph and subgraph
|
||||
for await (const chunk of await graph.stream({
|
||||
messages: [{ role: "user", content: "what's the weather in la" }]
|
||||
}, { subgraphs: true })) {
|
||||
console.log(chunk);
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# INVALID_CHAT_HISTORY
|
||||
|
||||
This error is raised in the prebuilt [create_react_agent][langgraph.prebuilt.chat_agent_executor.create_react_agent] when the `call_model` graph node receives a malformed list of messages. Specifically, it is malformed when there are `AIMessages` with `tool_calls` (LLM requesting to call a tool) that do not have a corresponding `ToolMessage` (result of a tool invocation to return to the LLM).
|
||||
|
||||
There could be a few reasons you're seeing this error:
|
||||
|
||||
1. You manually passed a malformed list of messages when invoking the graph, e.g. `graph.invoke({'messages': [AIMessage(..., tool_calls=[...])]})`
|
||||
2. The graph was interrupted before receiving updates from the `tools` node (i.e. a list of ToolMessages)
|
||||
and you invoked it with a an input that is not None or a ToolMessage,
|
||||
e.g. `graph.invoke({'messages': [HumanMessage(...)]}, config)`.
|
||||
This interrupt could have been triggered in one of the following ways:
|
||||
- You manually set `interrupt_before = ['tools']` in `create_react_agent`
|
||||
- One of the tools raised an error that wasn't handled by the [ToolNode][langgraph.prebuilt.tool_node.ToolNode] (`"tools"`)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
To resolve this, you can do one of the following:
|
||||
|
||||
1. Don't invoke the graph with a malformed list of messages
|
||||
2. In case of an interrupt (manual or due to an error) you can:
|
||||
|
||||
- provide ToolMessages that match existing tool calls and call `graph.invoke({'messages': [ToolMessage(...)]})`.
|
||||
**NOTE**: this will append the messages to the history and run the graph from the START node.
|
||||
- manually update the state and resume the graph from the interrupt:
|
||||
|
||||
1. get the list of most recent messages from the graph state with `graph.get_state(config)`
|
||||
2. modify the list of messages to either remove unanswered tool calls from AIMessages
|
||||
or add ToolMessages with tool_call_ids that match unanswered tool calls
|
||||
3. call `graph.update_state(config, {'messages': ...})` with the modified list of messages
|
||||
4. resume the graph, e.g. call `graph.invoke(None, config)`
|
||||
@@ -7,3 +7,4 @@ Errors referenced below will have an `lc_error_code` property corresponding to o
|
||||
- [INVALID_CONCURRENT_GRAPH_UPDATE](./INVALID_CONCURRENT_GRAPH_UPDATE.md)
|
||||
- [INVALID_GRAPH_NODE_RETURN_VALUE](./INVALID_GRAPH_NODE_RETURN_VALUE.md)
|
||||
- [MULTIPLE_SUBGRAPHS](./MULTIPLE_SUBGRAPHS.md)
|
||||
- [INVALID_CHAT_HISTORY](./INVALID_CHAT_HISTORY.md)
|
||||
|
||||
@@ -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",
|
||||
@@ -1077,7 +1082,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"part_1_tools = [\n",
|
||||
" TavilySearchResults(max_results=1),\n",
|
||||
@@ -1893,7 +1898,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"part_2_tools = [\n",
|
||||
" TavilySearchResults(max_results=1),\n",
|
||||
@@ -2472,7 +2477,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# \"Read\"-only tools (such as retrievers) don't need a user confirmation to use\n",
|
||||
@@ -3183,7 +3188,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"update_flight_safe_tools = [search_flights]\n",
|
||||
"update_flight_sensitive_tools = [update_ticket_to_new_flight, cancel_ticket]\n",
|
||||
@@ -3215,7 +3220,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"book_hotel_safe_tools = [search_hotels]\n",
|
||||
"book_hotel_sensitive_tools = [book_hotel, update_hotel, cancel_hotel]\n",
|
||||
@@ -3247,7 +3252,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"book_car_rental_safe_tools = [search_car_rentals]\n",
|
||||
"book_car_rental_sensitive_tools = [\n",
|
||||
@@ -3282,7 +3287,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"\n",
|
||||
"book_excursion_safe_tools = [search_trip_recommendations]\n",
|
||||
"book_excursion_sensitive_tools = [book_excursion, update_excursion, cancel_excursion]\n",
|
||||
@@ -3389,7 +3394,7 @@
|
||||
" ),\n",
|
||||
" (\"placeholder\", \"{messages}\"),\n",
|
||||
" ]\n",
|
||||
").partial(time=datetime.now())\n",
|
||||
").partial(time=datetime.now)\n",
|
||||
"primary_assistant_tools = [\n",
|
||||
" TavilySearchResults(max_results=1),\n",
|
||||
" search_flights,\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
|
||||
|
||||
@@ -56,7 +54,8 @@ Learn from example implementations of graphs designed for specific scenarios and
|
||||
|
||||
- [Basic Reflection](reflection/reflection.ipynb): Prompt the agent to reflect on and revise its outputs
|
||||
- [Reflexion](reflexion/reflexion.ipynb): Critique missing and superfluous details to guide next steps
|
||||
- [Language Agent Tree Search](lats/lats.ipynb): Use reflection and rewards to drive a tree search over agents
|
||||
- [Tree of Thoughts](tot/tot.ipynb): Search over candidate solutions to a problem using a scored tree
|
||||
- [Language Agent Tree Search](lats/lats.ipynb): Use reflection and rewards to drive a monte-carlo tree search over agents
|
||||
- [Self-Discover Agent](self-discover/self-discover.ipynb): Analyze an agent that learns about its own capabilities
|
||||
|
||||
### Evaluation
|
||||
|
||||
@@ -5,21 +5,21 @@
|
||||
"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",
|
||||
"First, install the required packages:"
|
||||
"First, install the required packages and configure your environment:"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -33,14 +33,6 @@
|
||||
"%pip install -U langgraph langsmith langchain_anthropic"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a6d1e870-1bc0-4d44-86c0-96681ccf6113",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Next, set your API keys:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
@@ -120,27 +112,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 reducer functions 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/?h=add+messages#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/?h=add+messages#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 +354,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 +2035,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 +3125,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 +3167,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\""
|
||||
]
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%pip install -U --quiet langgraph langchain_anthropic\n",
|
||||
"%pip install -U --quiet tavily-python"
|
||||
"%pip install -U --quiet langgraph langchain_anthropic tavily-python"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -189,7 +188,7 @@
|
||||
" self.runnable = runnable\n",
|
||||
" self.validator = validator\n",
|
||||
"\n",
|
||||
" def respond(self, state: list):\n",
|
||||
" def respond(self, state: dict):\n",
|
||||
" response = []\n",
|
||||
" for attempt in range(3):\n",
|
||||
" response = self.runnable.invoke(\n",
|
||||
@@ -622,12 +621,6 @@
|
||||
"2. The 'reflections' can be paired with additional external feedback (such as validators), to further guide the actor.\n",
|
||||
"3. In the paper, 1 environment (AlfWorld) uses external memory. It does this by storing summaries of the reflections to an external store and using them in subsequent trials/invocations."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "39e44dd6",
|
||||
"metadata": {},
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
@@ -646,7 +639,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.11.9"
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
File diff suppressed because one or more lines are too long
+247
-6
@@ -25,6 +25,7 @@ theme:
|
||||
- navigation.sections
|
||||
- navigation.instant.prefetch
|
||||
- navigation.instant.progress
|
||||
- navigation.path
|
||||
- navigation.prune
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
@@ -54,6 +55,13 @@ plugins:
|
||||
- search:
|
||||
separator: '[\s\u200b\-_,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])'
|
||||
- autorefs
|
||||
- redirects:
|
||||
redirect_maps:
|
||||
'cloud/index.md': 'concepts/index.md#langgraph-platform'
|
||||
'cloud/how-tos/index.md': 'how-tos/index.md#langgraph-platform'
|
||||
'cloud/concepts/api.md': 'concepts/langgraph_server.md'
|
||||
'cloud/concepts/cloud.md': 'concepts/langgraph_cloud.md'
|
||||
'cloud/faq/studio.md': 'concepts/langgraph_studio.md#studio-faqs'
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
@@ -80,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:
|
||||
@@ -199,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;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ from langgraph.store.base import (
|
||||
Op,
|
||||
PutOp,
|
||||
Result,
|
||||
SearchItem,
|
||||
SearchOp,
|
||||
)
|
||||
|
||||
@@ -283,7 +284,7 @@ class DuckDBStore(BaseStore, BaseDuckDBStore[duckdb.DuckDBPyConnection]):
|
||||
|
||||
for cur, idx in cursors:
|
||||
rows = cur.fetchall()
|
||||
items = [_row_to_item(_convert_ns(row[0]), row) for row in rows]
|
||||
items = [_row_to_search_item(_convert_ns(row[0]), row) for row in rows]
|
||||
results[idx] = items
|
||||
|
||||
def _batch_list_namespaces_ops(
|
||||
@@ -376,6 +377,22 @@ def _row_to_item(
|
||||
)
|
||||
|
||||
|
||||
def _row_to_search_item(
|
||||
namespace: tuple[str, ...],
|
||||
row: tuple,
|
||||
) -> SearchItem:
|
||||
"""Convert a row from the database into an SearchItem."""
|
||||
# TODO: Add support for search
|
||||
_, key, val, created_at, updated_at = row
|
||||
return SearchItem(
|
||||
value=val if isinstance(val, dict) else json.loads(val),
|
||||
key=key,
|
||||
namespace=namespace,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _group_ops(ops: Iterable[Op]) -> tuple[dict[type, list[tuple[int, Op]]], int]:
|
||||
grouped_ops: dict[type, list[tuple[int, Op]]] = defaultdict(list)
|
||||
tot = 0
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
######################
|
||||
|
||||
start-postgres:
|
||||
POSTGRES_VERSION=${POSTGRES_VERSION:-16} docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait
|
||||
POSTGRES_VERSION=${POSTGRES_VERSION:-16} docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait || ( \
|
||||
echo "Failed to start PostgreSQL, printing logs..."; \
|
||||
docker compose -f tests/compose-postgres.yml logs; \
|
||||
exit 1 \
|
||||
)
|
||||
|
||||
stop-postgres:
|
||||
docker compose -f tests/compose-postgres.yml down
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import threading
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator, Optional, Sequence, Union
|
||||
from typing import Any, Optional
|
||||
|
||||
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 +18,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 +30,7 @@ class PostgresSaver(BasePostgresSaver):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Conn,
|
||||
conn: _internal.Conn,
|
||||
pipe: Optional[Pipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
@@ -52,6 +43,7 @@ class PostgresSaver(BasePostgresSaver):
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.lock = threading.Lock()
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
@@ -72,9 +64,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 +357,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 +378,24 @@ 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,24 @@
|
||||
"""Shared async utility functions for the Postgres checkpoint & storage classes."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import 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,22 @@
|
||||
"""Shared utility functions for the Postgres checkpoint & storage classes."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import 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,10 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Iterator, Optional, Sequence, Union
|
||||
from typing import Any, Optional
|
||||
|
||||
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 +18,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 +30,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: Conn,
|
||||
conn: _ainternal.Conn,
|
||||
pipe: Optional[AsyncPipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
@@ -55,6 +44,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 +69,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 +146,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 +207,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 +317,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,14 +338,26 @@ 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
|
||||
) as cur:
|
||||
async with (
|
||||
self.lock,
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
|
||||
def list(
|
||||
@@ -373,7 +386,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
while True:
|
||||
try:
|
||||
yield asyncio.run_coroutine_threadsafe(
|
||||
anext(aiter_),
|
||||
anext(aiter_), # noqa: F821
|
||||
self.loop,
|
||||
).result()
|
||||
except StopAsyncIteration:
|
||||
@@ -452,3 +465,6 @@ class AsyncPostgresSaver(BasePostgresSaver):
|
||||
return asyncio.run_coroutine_threadsafe(
|
||||
self.aput_writes(config, writes, task_id), self.loop
|
||||
).result()
|
||||
|
||||
|
||||
__all__ = ["AsyncPostgresSaver", "Conn"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import random
|
||||
from typing import Any, List, Optional, Sequence, Tuple, cast
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from psycopg.types.json import Jsonb
|
||||
@@ -84,7 +85,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 +134,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
INSERT_CHECKPOINT_WRITES_SQL = INSERT_CHECKPOINT_WRITES_SQL
|
||||
|
||||
jsonplus_serde = JsonPlusSerializer()
|
||||
supports_pipeline: bool
|
||||
|
||||
def _load_checkpoint(
|
||||
self,
|
||||
@@ -248,7 +250,7 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||
config: Optional[RunnableConfig],
|
||||
filter: MetadataInput,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
) -> Tuple[str, List[Any]]:
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""Return WHERE clause predicates for alist() given config, filter, before.
|
||||
|
||||
This method returns a tuple of a string and a tuple of values. The string
|
||||
|
||||
@@ -1,110 +1,241 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Iterable, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Iterable,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from typing import Any, Callable, Optional, Union, cast
|
||||
|
||||
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.store.base import GetOp, ListNamespacesOp, Op, PutOp, Result, SearchOp
|
||||
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 (
|
||||
_PLACEHOLDER,
|
||||
BasePostgresStore,
|
||||
PoolConfig,
|
||||
PostgresIndexConfig,
|
||||
Row,
|
||||
_decode_ns_bytes,
|
||||
_ensure_index_config,
|
||||
_group_ops,
|
||||
_row_to_item,
|
||||
_row_to_search_item,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[AsyncConnection]):
|
||||
__slots__ = ("_deserializer",)
|
||||
class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Conn]):
|
||||
__slots__ = (
|
||||
"_deserializer",
|
||||
"pipe",
|
||||
"lock",
|
||||
"supports_pipeline",
|
||||
"index_config",
|
||||
"embeddings",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: AsyncConnection[Any],
|
||||
conn: _ainternal.Conn,
|
||||
*,
|
||||
pipe: Optional[AsyncPipeline] = None,
|
||||
deserializer: Optional[
|
||||
Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]
|
||||
] = None,
|
||||
index: Optional[PostgresIndexConfig] = 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()
|
||||
self.index_config = index
|
||||
if self.index_config:
|
||||
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
|
||||
|
||||
else:
|
||||
self.embeddings = None
|
||||
|
||||
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()
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
*,
|
||||
pipeline: bool = False,
|
||||
pool_config: Optional[PoolConfig] = None,
|
||||
index: Optional[PostgresIndexConfig] = 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.
|
||||
index (Optional[PostgresIndexConfig]): The embedding config.
|
||||
|
||||
Returns:
|
||||
AsyncPostgresStore: A new AsyncPostgresStore instance.
|
||||
"""
|
||||
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, index=index)
|
||||
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, index=index)
|
||||
else:
|
||||
yield cls(conn=conn, index=index)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the store database asynchronously.
|
||||
|
||||
This method creates the necessary tables in the Postgres database if they don't
|
||||
already exist and runs database migrations. It MUST be called directly by the user
|
||||
the first time the store is used.
|
||||
"""
|
||||
|
||||
async def _get_version(cur: AsyncCursor[DictRow], table: str) -> int:
|
||||
try:
|
||||
await cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
version = -1
|
||||
else:
|
||||
version = row["v"]
|
||||
except UndefinedTable:
|
||||
version = -1
|
||||
await cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
"""
|
||||
)
|
||||
return version
|
||||
|
||||
async with self._cursor() as cur:
|
||||
version = await _get_version(cur, table="store_migrations")
|
||||
for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
|
||||
await cur.execute(sql)
|
||||
await cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
||||
|
||||
if self.index_config:
|
||||
version = await _get_version(cur, table="vector_migrations")
|
||||
for v, migration in enumerate(
|
||||
self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
|
||||
):
|
||||
sql = migration.sql
|
||||
if migration.params:
|
||||
params = {
|
||||
k: v(self) if v is not None and callable(v) else v
|
||||
for k, v in migration.params.items()
|
||||
}
|
||||
sql = sql % params
|
||||
await cur.execute(sql)
|
||||
await cur.execute(
|
||||
"INSERT INTO vector_migrations (v) VALUES (%s)", (v,)
|
||||
)
|
||||
|
||||
async def _execute_batch(
|
||||
self,
|
||||
grouped_ops: dict,
|
||||
results: list[Result],
|
||||
conn: AsyncConnection[DictRow],
|
||||
) -> None:
|
||||
async with self._cursor(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,29 +250,59 @@ 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)
|
||||
queries, embedding_request = self._prepare_batch_PUT_queries(put_ops)
|
||||
if embedding_request:
|
||||
if self.embeddings is None:
|
||||
# Should not get here since the embedding config is required
|
||||
# to return an embedding_request above
|
||||
raise ValueError(
|
||||
"Embedding configuration is required for vector operations "
|
||||
f"(for semantic search). "
|
||||
f"Please provide an EmbeddingConfig when initializing the {self.__class__.__name__}."
|
||||
)
|
||||
query, txt_params = embedding_request
|
||||
vectors = await self.embeddings.aembed_documents(
|
||||
[param[-1] for param in txt_params]
|
||||
)
|
||||
queries.append(
|
||||
(
|
||||
query,
|
||||
[
|
||||
p
|
||||
for (ns, k, pathname, _), vector in zip(txt_params, vectors)
|
||||
for p in (ns, k, pathname, vector)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
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]] = []
|
||||
queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
|
||||
|
||||
for (query, params), (idx, _) in zip(queries, search_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
if embedding_requests and self.embeddings:
|
||||
vectors = await self.embeddings.aembed_documents(
|
||||
[query for _, query in embedding_requests]
|
||||
)
|
||||
for (idx, _), vector in zip(embedding_requests, vectors):
|
||||
_paramslist = queries[idx][1]
|
||||
for i in range(len(_paramslist)):
|
||||
if _paramslist[i] is _PLACEHOLDER:
|
||||
_paramslist[i] = vector
|
||||
|
||||
for (idx, _), (query, params) in zip(search_ops, queries):
|
||||
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(
|
||||
_row_to_search_item(
|
||||
_decode_ns_bytes(row["prefix"]), row, loader=self._deserializer
|
||||
)
|
||||
for row in rows
|
||||
@@ -152,67 +313,57 @@ 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
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
) -> AsyncIterator["AsyncPostgresStore"]:
|
||||
"""Create a new AsyncPostgresStore instance from a connection string.
|
||||
async def _cursor(
|
||||
self, *, pipeline: bool = False
|
||||
) -> AsyncIterator[AsyncCursor[DictRow]]:
|
||||
"""Create a database cursor as a context manager.
|
||||
|
||||
Args:
|
||||
conn_string (str): The Postgres connection info string.
|
||||
|
||||
Returns:
|
||||
AsyncPostgresStore: A new AsyncPostgresStore instance.
|
||||
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.
|
||||
"""
|
||||
async with await AsyncConnection.connect(
|
||||
conn_string, autocommit=True, prepare_threshold=0, row_factory=dict_row
|
||||
) as conn:
|
||||
yield cls(conn=conn)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the store database asynchronously.
|
||||
|
||||
This method creates the necessary tables in the Postgres database if they don't
|
||||
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
|
||||
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
|
||||
# used at a time
|
||||
try:
|
||||
async with conn.cursor(binary=True, row_factory=dict_row) as cur:
|
||||
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, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
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,))
|
||||
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) as cur,
|
||||
):
|
||||
yield cur
|
||||
|
||||
@@ -1,44 +1,64 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Generic,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
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,
|
||||
IndexConfig,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
Op,
|
||||
PutOp,
|
||||
Result,
|
||||
SearchItem,
|
||||
SearchOp,
|
||||
ensure_embeddings,
|
||||
get_text_at_path,
|
||||
tokenize_path,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
class Migration(NamedTuple):
|
||||
"""A database migration with optional conditions and parameters."""
|
||||
|
||||
sql: str
|
||||
params: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
MIGRATIONS: Sequence[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS store (
|
||||
-- 'prefix' represents the doc's 'namespace'
|
||||
@@ -56,13 +76,100 @@ CREATE INDEX IF NOT EXISTS store_prefix_idx ON store USING btree (prefix text_pa
|
||||
""",
|
||||
]
|
||||
|
||||
C = TypeVar("C", bound=BaseConnection)
|
||||
VECTOR_MIGRATIONS: Sequence[Migration] = [
|
||||
Migration(
|
||||
"""
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
""",
|
||||
),
|
||||
Migration(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS store_vectors (
|
||||
prefix text NOT NULL,
|
||||
key text NOT NULL,
|
||||
field_name text NOT NULL,
|
||||
embedding %(vector_type)s(%(dims)s),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (prefix, key, field_name),
|
||||
FOREIGN KEY (prefix, key) REFERENCES store(prefix, key) ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
params={
|
||||
"dims": lambda store: store.index_config["dims"],
|
||||
"vector_type": lambda store: (
|
||||
cast(PostgresIndexConfig, store.index_config)
|
||||
.get("ann_index_config", {})
|
||||
.get("vector_type", "vector")
|
||||
),
|
||||
},
|
||||
),
|
||||
# TODO: Add an HNSW or IVFFlat index depending on config
|
||||
# First must improve the search query when filtering by
|
||||
# namespace
|
||||
]
|
||||
|
||||
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 ANNIndexConfig(TypedDict, total=False):
|
||||
"""Configuration for vector index in PostgreSQL store."""
|
||||
|
||||
vector_type: Literal["vector", "halfvec"]
|
||||
"""Type of vector storage to use.
|
||||
Options:
|
||||
- 'vector': Regular vectors (default)
|
||||
- 'halfvec': Half-precision vectors for reduced memory usage
|
||||
"""
|
||||
|
||||
|
||||
class PostgresIndexConfig(IndexConfig, total=False):
|
||||
"""Configuration for vector embeddings in PostgreSQL store with pgvector-specific options.
|
||||
|
||||
Extends EmbeddingConfig with additional configuration for pgvector index and vector types.
|
||||
"""
|
||||
|
||||
ann_index_config: ANNIndexConfig
|
||||
"""Specific configuration for the chosen index type (HNSW or IVF Flat)."""
|
||||
distance_type: Literal["l2", "inner_product", "cosine"]
|
||||
"""Distance metric to use for vector similarity search:
|
||||
- 'l2': Euclidean distance
|
||||
- 'inner_product': Dot product
|
||||
- 'cosine': Cosine similarity
|
||||
"""
|
||||
|
||||
|
||||
class BasePostgresStore(Generic[C]):
|
||||
MIGRATIONS = MIGRATIONS
|
||||
VECTOR_MIGRATIONS = VECTOR_MIGRATIONS
|
||||
conn: C
|
||||
_deserializer: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]]
|
||||
index_config: Optional[PostgresIndexConfig]
|
||||
|
||||
def _get_batch_GET_ops_queries(
|
||||
self,
|
||||
@@ -84,13 +191,21 @@ class BasePostgresStore(Generic[C]):
|
||||
results.append((query, params, namespace, items))
|
||||
return results
|
||||
|
||||
def _get_batch_PUT_queries(
|
||||
def _prepare_batch_PUT_queries(
|
||||
self,
|
||||
put_ops: Sequence[tuple[int, PutOp]],
|
||||
) -> list[tuple[str, Sequence]]:
|
||||
) -> tuple[
|
||||
list[tuple[str, Sequence]],
|
||||
Optional[tuple[str, Sequence[tuple[str, str, str, str]]]],
|
||||
]:
|
||||
# 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:
|
||||
@@ -109,60 +224,182 @@ class BasePostgresStore(Generic[C]):
|
||||
)
|
||||
params = (_namespace_to_text(namespace), *keys)
|
||||
queries.append((query, params))
|
||||
embedding_request: Optional[tuple[str, Sequence[tuple[str, str, str, str]]]] = (
|
||||
None
|
||||
)
|
||||
if inserts:
|
||||
values = []
|
||||
insertion_params = []
|
||||
vector_values = []
|
||||
embedding_request_params = []
|
||||
|
||||
# First handle main store insertions
|
||||
for op in inserts:
|
||||
values.append("(%s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
|
||||
insertion_params.extend(
|
||||
[
|
||||
_namespace_to_text(op.namespace),
|
||||
op.key,
|
||||
Jsonb(op.value),
|
||||
Jsonb(cast(dict, op.value)),
|
||||
]
|
||||
)
|
||||
|
||||
# Then handle embeddings if configured
|
||||
if self.index_config:
|
||||
for op in inserts:
|
||||
if op.index is False:
|
||||
continue
|
||||
value = op.value
|
||||
ns = _namespace_to_text(op.namespace)
|
||||
k = op.key
|
||||
|
||||
if op.index is None:
|
||||
paths = self.index_config["__tokenized_fields"]
|
||||
else:
|
||||
paths = [(ix, tokenize_path(ix)) for ix in op.index]
|
||||
|
||||
for path, tokenized_path in paths:
|
||||
texts = get_text_at_path(value, tokenized_path)
|
||||
for i, text in enumerate(texts):
|
||||
pathname = f"{path}.{i}" if len(texts) > 1 else path
|
||||
vector_values.append(
|
||||
"(%s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
|
||||
)
|
||||
embedding_request_params.append((ns, k, pathname, text))
|
||||
|
||||
values_str = ",".join(values)
|
||||
query = f"""
|
||||
INSERT INTO store (prefix, key, value, created_at, updated_at)
|
||||
VALUES {values_str}
|
||||
ON CONFLICT (prefix, key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP
|
||||
SET value = EXCLUDED.value,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
queries.append((query, insertion_params))
|
||||
|
||||
return queries
|
||||
if vector_values:
|
||||
values_str = ",".join(vector_values)
|
||||
query = f"""
|
||||
INSERT INTO store_vectors (prefix, key, field_name, embedding, created_at, updated_at)
|
||||
VALUES {values_str}
|
||||
ON CONFLICT (prefix, key, field_name) DO UPDATE
|
||||
SET embedding = EXCLUDED.embedding,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
embedding_request = (query, embedding_request_params)
|
||||
|
||||
def _get_batch_search_queries(
|
||||
return queries, embedding_request
|
||||
|
||||
def _prepare_batch_search_queries(
|
||||
self,
|
||||
search_ops: Sequence[tuple[int, SearchOp]],
|
||||
) -> list[tuple[str, Sequence]]:
|
||||
queries: list[tuple[str, Sequence]] = []
|
||||
for _, op in search_ops:
|
||||
query = """
|
||||
SELECT prefix, key, value, created_at, updated_at
|
||||
FROM store
|
||||
WHERE prefix LIKE %s
|
||||
"""
|
||||
params: list = [f"{_namespace_to_text(op.namespace_prefix)}%"]
|
||||
) -> tuple[
|
||||
list[tuple[str, list[Union[None, str, list[float]]]]], # queries, params
|
||||
list[tuple[int, str]], # idx, query_text pairs to embed
|
||||
]:
|
||||
queries = []
|
||||
embedding_requests = []
|
||||
|
||||
for idx, (_, op) in enumerate(search_ops):
|
||||
# Build filter conditions first
|
||||
filter_params = []
|
||||
filter_conditions = []
|
||||
if op.filter:
|
||||
filter_conditions = []
|
||||
for key, value in op.filter.items():
|
||||
if isinstance(value, list):
|
||||
filter_conditions.append("value->%s @> %s::jsonb")
|
||||
params.extend([key, json.dumps(value)])
|
||||
if isinstance(value, dict):
|
||||
for op_name, val in value.items():
|
||||
condition, filter_params_ = self._get_filter_condition(
|
||||
key, op_name, val
|
||||
)
|
||||
filter_conditions.append(condition)
|
||||
filter_params.extend(filter_params_)
|
||||
else:
|
||||
filter_conditions.append("value->%s = %s::jsonb")
|
||||
params.extend([key, json.dumps(value)])
|
||||
query += " AND " + " AND ".join(filter_conditions)
|
||||
filter_params.extend([key, json.dumps(value)])
|
||||
|
||||
# Note: we will need to not do this if sim/keyword search
|
||||
# is used
|
||||
query += " ORDER BY updated_at DESC LIMIT %s OFFSET %s"
|
||||
params.extend([op.limit, op.offset])
|
||||
# Vector search branch
|
||||
if op.query and self.index_config:
|
||||
embedding_requests.append((idx, op.query))
|
||||
|
||||
queries.append((query, params))
|
||||
return queries
|
||||
score_operator = _get_distance_operator(self)
|
||||
vector_type = (
|
||||
cast(PostgresIndexConfig, self.index_config)
|
||||
.get("ann_index_config", {})
|
||||
.get("vector_type", "vector")
|
||||
)
|
||||
|
||||
if (
|
||||
vector_type == "bit"
|
||||
and self.index_config.get("distance_type") == "hamming"
|
||||
):
|
||||
score_operator = score_operator % (
|
||||
"%s",
|
||||
self.index_config["dims"],
|
||||
)
|
||||
else:
|
||||
score_operator = score_operator % (
|
||||
"%s",
|
||||
vector_type,
|
||||
)
|
||||
|
||||
vectors_per_doc_estimate = self.index_config["__estimated_num_vectors"]
|
||||
expanded_limit = (op.limit * vectors_per_doc_estimate * 2) + 1
|
||||
|
||||
# Vector search with CTE for proper score handling
|
||||
filter_str = (
|
||||
""
|
||||
if not filter_conditions
|
||||
else " AND " + " AND ".join(filter_conditions)
|
||||
)
|
||||
base_query = f"""
|
||||
WITH scored AS (
|
||||
SELECT s.prefix, s.key, s.value, s.created_at, s.updated_at, {score_operator} AS score
|
||||
FROM store s
|
||||
JOIN store_vectors sv ON s.prefix = sv.prefix AND s.key = sv.key
|
||||
WHERE s.prefix LIKE %s {filter_str}
|
||||
ORDER BY {score_operator} DESC
|
||||
LIMIT %s
|
||||
)
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (prefix, key)
|
||||
prefix, key, value, created_at, updated_at, score
|
||||
FROM scored
|
||||
ORDER BY prefix, key, score DESC
|
||||
) AS unique_docs
|
||||
ORDER BY score DESC
|
||||
LIMIT %s
|
||||
OFFSET %s
|
||||
"""
|
||||
params = [
|
||||
_PLACEHOLDER, # Vector placeholder
|
||||
f"{_namespace_to_text(op.namespace_prefix)}%",
|
||||
*filter_params,
|
||||
_PLACEHOLDER,
|
||||
expanded_limit,
|
||||
op.limit,
|
||||
op.offset,
|
||||
]
|
||||
|
||||
# Regular search branch
|
||||
else:
|
||||
base_query = """
|
||||
SELECT prefix, key, value, created_at, updated_at
|
||||
FROM store
|
||||
WHERE prefix LIKE %s
|
||||
"""
|
||||
params = [f"{_namespace_to_text(op.namespace_prefix)}%"]
|
||||
|
||||
if filter_conditions:
|
||||
params.extend(filter_params)
|
||||
base_query += " AND " + " AND ".join(filter_conditions)
|
||||
|
||||
base_query += " ORDER BY updated_at DESC"
|
||||
base_query += " LIMIT %s OFFSET %s"
|
||||
params.extend([op.limit, op.offset])
|
||||
|
||||
queries.append((base_query, params))
|
||||
|
||||
return queries, embedding_requests
|
||||
|
||||
def _get_batch_list_namespaces_queries(
|
||||
self,
|
||||
@@ -214,45 +451,167 @@ class BasePostgresStore(Generic[C]):
|
||||
|
||||
query += " ORDER BY truncated_prefix LIMIT %s OFFSET %s"
|
||||
params.extend([op.limit, op.offset])
|
||||
queries.append((query, params))
|
||||
queries.append((query, tuple(params)))
|
||||
|
||||
return queries
|
||||
|
||||
def _get_filter_condition(self, key: str, op: str, value: Any) -> tuple[str, list]:
|
||||
"""Helper to generate filter conditions."""
|
||||
if op == "$eq":
|
||||
return "value->%s = %s::jsonb", [key, json.dumps(value)]
|
||||
elif op == "$gt":
|
||||
return "value->>%s > %s", [key, str(value)]
|
||||
elif op == "$gte":
|
||||
return "value->>%s >= %s", [key, str(value)]
|
||||
elif op == "$lt":
|
||||
return "value->>%s < %s", [key, str(value)]
|
||||
elif op == "$lte":
|
||||
return "value->>%s <= %s", [key, str(value)]
|
||||
elif op == "$ne":
|
||||
return "value->%s != %s::jsonb", [key, json.dumps(value)]
|
||||
else:
|
||||
raise ValueError(f"Unsupported operator: {op}")
|
||||
|
||||
class PostgresStore(BaseStore, BasePostgresStore[Connection]):
|
||||
__slots__ = ("_deserializer",)
|
||||
|
||||
class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
|
||||
__slots__ = (
|
||||
"_deserializer",
|
||||
"pipe",
|
||||
"lock",
|
||||
"supports_pipeline",
|
||||
"index_config",
|
||||
"embeddings",
|
||||
)
|
||||
|
||||
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,
|
||||
index: Optional[PostgresIndexConfig] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._deserializer = deserializer
|
||||
self.conn = conn
|
||||
self.pipe = pipe
|
||||
self.supports_pipeline = Capabilities().has_pipeline()
|
||||
self.lock = threading.Lock()
|
||||
self.index_config = index
|
||||
if self.index_config:
|
||||
self.embeddings, self.index_config = _ensure_index_config(self.index_config)
|
||||
else:
|
||||
self.embeddings = None
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def from_conn_string(
|
||||
cls,
|
||||
conn_string: str,
|
||||
*,
|
||||
pipeline: bool = False,
|
||||
pool_config: Optional[PoolConfig] = None,
|
||||
index: Optional[PostgresIndexConfig] = 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
|
||||
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.
|
||||
index (Optional[PostgresIndexConfig]): The index configuration for the store.
|
||||
|
||||
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, index=index)
|
||||
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, index=index)
|
||||
else:
|
||||
yield cls(conn, index=index)
|
||||
|
||||
@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 +621,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 +652,80 @@ 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)
|
||||
queries, embedding_request = self._prepare_batch_PUT_queries(put_ops)
|
||||
if embedding_request:
|
||||
if self.embeddings is None:
|
||||
# Should not get here since the embedding config is required
|
||||
# to return an embedding_request above
|
||||
raise ValueError(
|
||||
"Embedding configuration is required for vector operations "
|
||||
f"(for semantic search). "
|
||||
f"Please provide an Embeddings when initializing the {self.__class__.__name__}."
|
||||
)
|
||||
query, txt_params = embedding_request
|
||||
# Update the params to replace the raw text with the vectors
|
||||
vectors = self.embeddings.embed_documents(
|
||||
[param[-1] for param in txt_params]
|
||||
)
|
||||
queries.append(
|
||||
(
|
||||
query,
|
||||
[
|
||||
p
|
||||
for (ns, k, pathname, _), vector in zip(txt_params, vectors)
|
||||
for p in (ns, k, pathname, vector)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
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]] = []
|
||||
queries, embedding_requests = self._prepare_batch_search_queries(search_ops)
|
||||
|
||||
for (query, params), (idx, _) in zip(queries, search_ops):
|
||||
cur = self.conn.cursor(binary=True)
|
||||
if embedding_requests and self.embeddings:
|
||||
embeddings = self.embeddings.embed_documents(
|
||||
[query for _, query in embedding_requests]
|
||||
)
|
||||
for (idx, _), embedding in zip(embedding_requests, embeddings):
|
||||
_paramslist = queries[idx][1]
|
||||
for i in range(len(_paramslist)):
|
||||
if _paramslist[i] is _PLACEHOLDER:
|
||||
_paramslist[i] = embedding
|
||||
|
||||
for (idx, _), (query, params) in zip(search_ops, queries):
|
||||
# Execute the actual query
|
||||
cur.execute(query, params)
|
||||
cursors.append((cur, idx))
|
||||
|
||||
for cur, idx in cursors:
|
||||
rows = cast(list[Row], cur.fetchall())
|
||||
items = [
|
||||
_row_to_item(
|
||||
results[idx] = [
|
||||
_row_to_search_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,31 +734,47 @@ 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:
|
||||
|
||||
def _get_version(cur: Cursor[dict[str, Any]], table: str) -> int:
|
||||
try:
|
||||
cur.execute("SELECT v FROM store_migrations ORDER BY v DESC LIMIT 1")
|
||||
cur.execute(f"SELECT v FROM {table} ORDER BY v DESC LIMIT 1")
|
||||
row = cast(dict, cur.fetchone())
|
||||
if row is None:
|
||||
version = -1
|
||||
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 (
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
v INTEGER PRIMARY KEY
|
||||
)
|
||||
"""
|
||||
)
|
||||
for v, migration in enumerate(
|
||||
self.MIGRATIONS[version + 1 :], start=version + 1
|
||||
):
|
||||
cur.execute(migration)
|
||||
return version
|
||||
|
||||
with self._cursor() as cur:
|
||||
version = _get_version(cur, table="store_migrations")
|
||||
for v, sql in enumerate(self.MIGRATIONS[version + 1 :], start=version + 1):
|
||||
cur.execute(sql)
|
||||
cur.execute("INSERT INTO store_migrations (v) VALUES (%s)", (v,))
|
||||
|
||||
if self.index_config:
|
||||
version = _get_version(cur, table="vector_migrations")
|
||||
for v, migration in enumerate(
|
||||
self.VECTOR_MIGRATIONS[version + 1 :], start=version + 1
|
||||
):
|
||||
sql = migration.sql
|
||||
if migration.params:
|
||||
params = {
|
||||
k: v(self) if v is not None and callable(v) else v
|
||||
for k, v in migration.params.items()
|
||||
}
|
||||
sql = sql % params
|
||||
cur.execute(sql)
|
||||
cur.execute("INSERT INTO vector_migrations (v) VALUES (%s)", (v,))
|
||||
|
||||
|
||||
class Row(TypedDict):
|
||||
key: str
|
||||
@@ -401,6 +784,45 @@ class Row(TypedDict):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# Private utilities
|
||||
|
||||
_DEFAULT_ANN_CONFIG = ANNIndexConfig(
|
||||
vector_type="vector",
|
||||
)
|
||||
|
||||
|
||||
def _get_vector_type_ops(store: BasePostgresStore) -> str:
|
||||
"""Get the vector type operator class based on config."""
|
||||
if not store.index_config:
|
||||
return "vector_cosine_ops"
|
||||
|
||||
config = cast(PostgresIndexConfig, store.index_config)
|
||||
index_config = config.get("ann_index_config", _DEFAULT_ANN_CONFIG).copy()
|
||||
vector_type = cast(str, index_config.get("vector_type", "vector"))
|
||||
if vector_type not in ("vector", "halfvec"):
|
||||
raise ValueError(
|
||||
f"Vector type must be 'vector' or 'halfvec', got {vector_type}"
|
||||
)
|
||||
|
||||
distance_type = config.get("distance_type", "cosine")
|
||||
|
||||
# For regular vectors
|
||||
type_prefix = {"vector": "vector", "halfvec": "halfvec"}[vector_type]
|
||||
|
||||
if distance_type not in ("l2", "inner_product", "cosine"):
|
||||
raise ValueError(
|
||||
f"Vector type {vector_type} only supports 'l2', 'inner_product', or 'cosine' distance, got {distance_type}"
|
||||
)
|
||||
|
||||
distance_suffix = {
|
||||
"l2": "l2_ops",
|
||||
"inner_product": "ip_ops",
|
||||
"cosine": "cosine_ops",
|
||||
}[distance_type]
|
||||
|
||||
return f"{type_prefix}_{distance_suffix}"
|
||||
|
||||
|
||||
def _namespace_to_text(
|
||||
namespace: tuple[str, ...], handle_wildcards: bool = False
|
||||
) -> str:
|
||||
@@ -416,15 +838,51 @@ def _row_to_item(
|
||||
*,
|
||||
loader: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]] = None,
|
||||
) -> Item:
|
||||
"""Convert a row from the database into an Item.
|
||||
|
||||
Args:
|
||||
namespace: Item namespace
|
||||
row: Database row
|
||||
loader: Optional value loader for non-dict values
|
||||
"""
|
||||
val = row["value"]
|
||||
if not isinstance(val, dict):
|
||||
val = (loader or _json_loads)(val)
|
||||
|
||||
kwargs = {
|
||||
"key": row["key"],
|
||||
"namespace": namespace,
|
||||
"value": val,
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
return Item(**kwargs)
|
||||
|
||||
|
||||
def _row_to_search_item(
|
||||
namespace: tuple[str, ...],
|
||||
row: Row,
|
||||
*,
|
||||
loader: Optional[Callable[[Union[bytes, orjson.Fragment]], dict[str, Any]]] = None,
|
||||
) -> SearchItem:
|
||||
"""Convert a row from the database into an Item."""
|
||||
loader = loader or _json_loads
|
||||
val = row["value"]
|
||||
return Item(
|
||||
score = row.get("score")
|
||||
if score is not None:
|
||||
try:
|
||||
score = float(score) # type: ignore[arg-type]
|
||||
except ValueError:
|
||||
logger.warning("Invalid score: %s", score)
|
||||
score = None
|
||||
return SearchItem(
|
||||
value=val if isinstance(val, dict) else loader(val),
|
||||
key=row["key"],
|
||||
namespace=namespace,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
score=score,
|
||||
)
|
||||
|
||||
|
||||
@@ -455,3 +913,62 @@ def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
|
||||
if isinstance(namespace, bytes):
|
||||
namespace = namespace.decode()[1:]
|
||||
return tuple(namespace.split("."))
|
||||
|
||||
|
||||
def _get_distance_operator(store: Any) -> str:
|
||||
"""Get the distance operator and score expression based on config."""
|
||||
# Note: Today, we are not using ANN indices due to restrictions
|
||||
# on PGVector's support for mixing vector and non-vector filters
|
||||
# To use the index, PGVector expects:
|
||||
# - ORDER BY the operator NOT an expression (even negation blocks it)
|
||||
# - ASCENDING order
|
||||
# - Any WHERE clause should be over a partial index.
|
||||
# If we violate any of these, it will use a sequential scan
|
||||
# See https://github.com/pgvector/pgvector/issues/216 and the
|
||||
# pgvector documentation for more details.
|
||||
if not store.index_config:
|
||||
raise ValueError(
|
||||
"Embedding configuration is required for vector operations "
|
||||
f"(for semantic search). "
|
||||
f"Please provide an Embeddings when initializing the {store.__class__.__name__}."
|
||||
)
|
||||
|
||||
config = cast(PostgresIndexConfig, store.index_config)
|
||||
distance_type = config.get("distance_type", "cosine")
|
||||
|
||||
if distance_type == "l2":
|
||||
return "1 - (sv.embedding <-> %s::%s)"
|
||||
elif distance_type == "inner_product":
|
||||
return "-(sv.embedding <#> %s::%s)"
|
||||
else: # cosine
|
||||
return "1 - (sv.embedding <=> %s::%s)"
|
||||
|
||||
|
||||
def _ensure_index_config(
|
||||
index_config: PostgresIndexConfig,
|
||||
) -> tuple[Optional["Embeddings"], PostgresIndexConfig]:
|
||||
index_config = index_config.copy()
|
||||
tokenized: list[tuple[str, Union[Literal["$"], list[str]]]] = []
|
||||
tot = 0
|
||||
text_fields = index_config.get("text_fields") or ["$"]
|
||||
if isinstance(text_fields, str):
|
||||
text_fields = [text_fields]
|
||||
if not isinstance(text_fields, list):
|
||||
raise ValueError(f"Text fields must be a list or a string. Got {text_fields}")
|
||||
for p in text_fields:
|
||||
if p == "$":
|
||||
tokenized.append((p, "$"))
|
||||
tot += 1
|
||||
else:
|
||||
toks = tokenize_path(p)
|
||||
tokenized.append((p, toks))
|
||||
tot += len(toks)
|
||||
index_config["__tokenized_fields"] = tokenized
|
||||
index_config["__estimated_num_vectors"] = tot
|
||||
embeddings = ensure_embeddings(
|
||||
index_config.get("embed"),
|
||||
)
|
||||
return embeddings, index_config
|
||||
|
||||
|
||||
_PLACEHOLDER = object()
|
||||
|
||||
Generated
+529
-426
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "2.0.2"
|
||||
version = "2.0.6"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -10,10 +10,10 @@ packages = [{ include = "langgraph" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
langgraph-checkpoint = "^2.0.2"
|
||||
langgraph-checkpoint = "^2.0.7"
|
||||
orjson = ">=3.10.1"
|
||||
psycopg = "^3.0.0"
|
||||
psycopg-pool = "^3.0.0"
|
||||
psycopg = "^3.2.0"
|
||||
psycopg-pool = "^3.2.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.6.2"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:${POSTGRES_VERSION:-16}
|
||||
image: pgvector/pgvector:pg${POSTGRES_VERSION:-16}
|
||||
ports:
|
||||
- "5441:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
command: ["postgres", "-c", "shared_preload_libraries=vector"]
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from typing import AsyncIterator
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.errors import UndefinedTable
|
||||
from psycopg.rows import DictRow, dict_row
|
||||
|
||||
from tests.embed_test_utils import CharacterEmbeddings
|
||||
|
||||
DEFAULT_URI = "postgres://postgres:postgres@localhost:5441/postgres?sslmode=disable"
|
||||
|
||||
|
||||
@@ -24,6 +26,18 @@ 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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_embeddings() -> CharacterEmbeddings:
|
||||
return CharacterEmbeddings(dims=500)
|
||||
|
||||
|
||||
VECTOR_TYPES = ["vector", "halfvec"]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Embedding utilities for testing."""
|
||||
|
||||
import math
|
||||
import random
|
||||
from collections import Counter, defaultdict
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
|
||||
class CharacterEmbeddings(Embeddings):
|
||||
"""Simple character-frequency based embeddings using random projections."""
|
||||
|
||||
def __init__(self, dims: int = 50, seed: int = 42):
|
||||
"""Initialize with embedding dimensions and random seed."""
|
||||
self._rng = random.Random(seed)
|
||||
self.dims = dims
|
||||
# Create projection vector for each character lazily
|
||||
self._char_projections: defaultdict[str, list[float]] = defaultdict(
|
||||
lambda: [
|
||||
self._rng.gauss(0, 1 / math.sqrt(self.dims)) for _ in range(self.dims)
|
||||
]
|
||||
)
|
||||
|
||||
def _embed_one(self, text: str) -> list[float]:
|
||||
"""Embed a single text."""
|
||||
counts = Counter(text)
|
||||
total = sum(counts.values())
|
||||
|
||||
if total == 0:
|
||||
return [0.0] * self.dims
|
||||
|
||||
embedding = [0.0] * self.dims
|
||||
for char, count in counts.items():
|
||||
weight = count / total
|
||||
char_proj = self._char_projections[char]
|
||||
for i, proj in enumerate(char_proj):
|
||||
embedding[i] += weight * proj
|
||||
|
||||
norm = math.sqrt(sum(x * x for x in embedding))
|
||||
if norm > 0:
|
||||
embedding = [x / norm for x in embedding]
|
||||
|
||||
return embedding
|
||||
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embed a list of documents."""
|
||||
return [self._embed_one(text) for text in texts]
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
"""Embed a query string."""
|
||||
return self._embed_one(text)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return isinstance(other, CharacterEmbeddings) and self.dims == other.dims
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from conftest import DEFAULT_URI # type: ignore
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
@@ -11,6 +10,7 @@ from langgraph.checkpoint.base import (
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
|
||||
|
||||
class TestAsyncPostgresSaver:
|
||||
|
||||
@@ -1,114 +1,84 @@
|
||||
# type: ignore
|
||||
import itertools
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
from conftest import DEFAULT_URI # type: ignore
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from langgraph.store.base import GetOp, Item, ListNamespacesOp, PutOp, SearchOp
|
||||
from langgraph.store.postgres import AsyncPostgresStore
|
||||
from tests.conftest import (
|
||||
DEFAULT_URI,
|
||||
VECTOR_TYPES,
|
||||
CharacterEmbeddings,
|
||||
)
|
||||
|
||||
|
||||
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 +88,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 +118,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 +139,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 +149,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,297 +170,338 @@ 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")]
|
||||
assert len(results[0]) == 2
|
||||
assert ("test", "namespace1") in results[0]
|
||||
assert ("test", "namespace2") in results[0]
|
||||
|
||||
|
||||
# The following use the actual DB connection
|
||||
@asynccontextmanager
|
||||
async def _create_vector_store(
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
text_fields: Optional[list[str]] = None,
|
||||
) -> AsyncIterator[AsyncPostgresStore]:
|
||||
"""Create a store with vector search enabled."""
|
||||
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 TestAsyncPostgresStore:
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup(self) -> None:
|
||||
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
|
||||
conn_string = f"{uri_base}/{database}{query_params}"
|
||||
admin_conn_string = DEFAULT_URI
|
||||
|
||||
index_config = {
|
||||
"dims": fake_embeddings.dims,
|
||||
"embed": fake_embeddings,
|
||||
"ann_index_config": {
|
||||
"vector_type": vector_type,
|
||||
},
|
||||
"distance_type": distance_type,
|
||||
"text_fields": text_fields,
|
||||
}
|
||||
|
||||
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,
|
||||
index=index_config,
|
||||
) as store:
|
||||
await store.setup()
|
||||
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_basic_store_ops(self) -> None:
|
||||
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
|
||||
namespace = ("test", "documents")
|
||||
item_id = "doc1"
|
||||
item_value = {"title": "Test Document", "content": "Hello, World!"}
|
||||
|
||||
await store.aput(namespace, item_id, item_value)
|
||||
item = await store.aget(namespace, item_id)
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=[
|
||||
(vector_type, distance_type)
|
||||
for vector_type in VECTOR_TYPES
|
||||
for distance_type in (
|
||||
["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
|
||||
)
|
||||
],
|
||||
ids=lambda p: f"{p[0]}_{p[1]}",
|
||||
)
|
||||
async def vector_store(
|
||||
request,
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
) -> AsyncIterator[AsyncPostgresStore]:
|
||||
"""Create a store with vector search enabled."""
|
||||
vector_type, distance_type = request.param
|
||||
async with _create_vector_store(
|
||||
vector_type, distance_type, fake_embeddings
|
||||
) as store:
|
||||
yield store
|
||||
|
||||
assert item
|
||||
assert item.namespace == namespace
|
||||
assert item.key == item_id
|
||||
assert item.value == item_value
|
||||
|
||||
updated_value = {
|
||||
"title": "Updated Test Document",
|
||||
"content": "Hello, LangGraph!",
|
||||
}
|
||||
await store.aput(namespace, item_id, updated_value)
|
||||
updated_item = await store.aget(namespace, item_id)
|
||||
async def test_vector_store_initialization(
|
||||
vector_store: AsyncPostgresStore, fake_embeddings: CharacterEmbeddings
|
||||
) -> None:
|
||||
"""Test store initialization with embedding config."""
|
||||
assert vector_store.index_config is not None
|
||||
assert vector_store.index_config["dims"] == fake_embeddings.dims
|
||||
if isinstance(vector_store.index_config["embed"], Embeddings):
|
||||
assert vector_store.index_config["embed"] == fake_embeddings
|
||||
|
||||
assert updated_item.value == updated_value
|
||||
assert updated_item.updated_at > item.updated_at
|
||||
different_namespace = ("test", "other_documents")
|
||||
item_in_different_namespace = await store.aget(different_namespace, item_id)
|
||||
assert item_in_different_namespace is None
|
||||
|
||||
new_item_id = "doc2"
|
||||
new_item_value = {"title": "Another Document", "content": "Greetings!"}
|
||||
await store.aput(namespace, new_item_id, new_item_value)
|
||||
async def test_vector_insert_with_auto_embedding(
|
||||
vector_store: AsyncPostgresStore,
|
||||
) -> None:
|
||||
"""Test inserting items that get auto-embedded."""
|
||||
docs = [
|
||||
("doc1", {"text": "short text"}),
|
||||
("doc2", {"text": "longer text document"}),
|
||||
("doc3", {"text": "longest text document here"}),
|
||||
("doc4", {"description": "text in description field"}),
|
||||
("doc5", {"content": "text in content field"}),
|
||||
("doc6", {"body": "text in body field"}),
|
||||
]
|
||||
|
||||
search_results = await store.asearch(["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)
|
||||
for key, value in docs:
|
||||
await vector_store.aput(("test",), key, value)
|
||||
|
||||
namespaces = await store.alist_namespaces(prefix=["test"])
|
||||
assert ("test", "documents") in namespaces
|
||||
results = await vector_store.asearch(("test",), query="long text")
|
||||
assert len(results) > 0
|
||||
|
||||
await store.adelete(namespace, item_id)
|
||||
await store.adelete(namespace, new_item_id)
|
||||
deleted_item = await store.aget(namespace, item_id)
|
||||
assert deleted_item is None
|
||||
doc_order = [r.key for r in results]
|
||||
assert "doc2" in doc_order
|
||||
assert "doc3" in doc_order
|
||||
|
||||
deleted_item = await store.aget(namespace, new_item_id)
|
||||
assert deleted_item is None
|
||||
|
||||
empty_search_results = await store.asearch(["test"], limit=10)
|
||||
assert len(empty_search_results) == 0
|
||||
async def test_vector_update_with_embedding(vector_store: AsyncPostgresStore) -> None:
|
||||
"""Test that updating items properly updates their embeddings."""
|
||||
await vector_store.aput(("test",), "doc1", {"text": "zany zebra Xerxes"})
|
||||
await vector_store.aput(("test",), "doc2", {"text": "something about dogs"})
|
||||
await vector_store.aput(("test",), "doc3", {"text": "text about birds"})
|
||||
|
||||
async def test_list_namespaces(self) -> None:
|
||||
async with AsyncPostgresStore.from_conn_string(DEFAULT_URI) as store:
|
||||
test_pref = str(uuid.uuid4())
|
||||
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),
|
||||
]
|
||||
results_initial = await vector_store.asearch(("test",), query="Zany Xerxes")
|
||||
assert len(results_initial) > 0
|
||||
assert results_initial[0].key == "doc1"
|
||||
initial_score = results_initial[0].score
|
||||
|
||||
for namespace in test_namespaces:
|
||||
await store.aput(namespace, "dummy", {"content": "dummy"})
|
||||
await vector_store.aput(("test",), "doc1", {"text": "new text about dogs"})
|
||||
|
||||
prefix_result = await store.alist_namespaces(prefix=[test_pref, "test"])
|
||||
assert len(prefix_result) == 4
|
||||
assert all([ns[1] == "test" for ns in prefix_result])
|
||||
results_after = await vector_store.asearch(("test",), query="Zany Xerxes")
|
||||
after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
|
||||
assert after_score < initial_score
|
||||
|
||||
specific_prefix_result = await store.alist_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]
|
||||
)
|
||||
results_new = await vector_store.asearch(("test",), query="new text about dogs")
|
||||
for r in results_new:
|
||||
if r.key == "doc1":
|
||||
assert r.score > after_score
|
||||
|
||||
suffix_result = await store.alist_namespaces(suffix=["public", test_pref])
|
||||
assert len(suffix_result) == 4
|
||||
assert all(ns[-2] == "public" for ns in suffix_result)
|
||||
# Don't index this one
|
||||
await vector_store.aput(
|
||||
("test",), "doc4", {"text": "new text about dogs"}, index=False
|
||||
)
|
||||
results_new = await vector_store.asearch(
|
||||
("test",), query="new text about dogs", limit=3
|
||||
)
|
||||
assert not any(r.key == "doc4" for r in results_new)
|
||||
|
||||
prefix_suffix_result = await store.alist_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
|
||||
)
|
||||
|
||||
wildcard_prefix_result = await store.alist_namespaces(
|
||||
prefix=[test_pref, "*", "documents"]
|
||||
)
|
||||
assert len(wildcard_prefix_result) == 5
|
||||
assert all(ns[2] == "documents" for ns in wildcard_prefix_result)
|
||||
async def test_vector_search_with_filters(vector_store: AsyncPostgresStore) -> None:
|
||||
"""Test combining vector search with filters."""
|
||||
docs = [
|
||||
("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
|
||||
("doc2", {"text": "red car", "color": "red", "score": 3.0}),
|
||||
("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
|
||||
("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
|
||||
]
|
||||
|
||||
wildcard_suffix_result = await store.alist_namespaces(
|
||||
suffix=["*", "public", test_pref]
|
||||
)
|
||||
assert len(wildcard_suffix_result) == 4
|
||||
assert all(ns[-2] == "public" for ns in wildcard_suffix_result)
|
||||
wildcard_single = await store.alist_namespaces(
|
||||
suffix=["some", "*", "public", test_pref]
|
||||
)
|
||||
assert len(wildcard_single) == 1
|
||||
assert wildcard_single[0] == (
|
||||
test_pref,
|
||||
"prod",
|
||||
"documents",
|
||||
"some",
|
||||
"nesting",
|
||||
"public",
|
||||
test_pref,
|
||||
)
|
||||
for key, value in docs:
|
||||
await vector_store.aput(("test",), key, value)
|
||||
|
||||
max_depth_result = await store.alist_namespaces(max_depth=3)
|
||||
assert all([len(ns) <= 3 for ns in max_depth_result])
|
||||
max_depth_result = await store.alist_namespaces(
|
||||
max_depth=4, prefix=[test_pref, "*", "documents"]
|
||||
)
|
||||
assert (
|
||||
len(set(tuple(res) for res in max_depth_result))
|
||||
== len(max_depth_result)
|
||||
== 5
|
||||
)
|
||||
results = await vector_store.asearch(
|
||||
("test",), query="apple", filter={"color": "red"}
|
||||
)
|
||||
assert len(results) == 2
|
||||
assert results[0].key == "doc1"
|
||||
|
||||
limit_result = await store.alist_namespaces(prefix=[test_pref], limit=3)
|
||||
assert len(limit_result) == 3
|
||||
results = await vector_store.asearch(
|
||||
("test",), query="car", filter={"color": "red"}
|
||||
)
|
||||
assert len(results) == 2
|
||||
assert results[0].key == "doc2"
|
||||
|
||||
offset_result = await store.alist_namespaces(prefix=[test_pref], offset=3)
|
||||
assert len(offset_result) == len(test_namespaces) - 3
|
||||
results = await vector_store.asearch(
|
||||
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
|
||||
)
|
||||
assert len(results) == 3
|
||||
assert results[0].key == "doc4"
|
||||
|
||||
empty_prefix_result = await store.alist_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
|
||||
)
|
||||
results = await vector_store.asearch(
|
||||
("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0].key == "doc3"
|
||||
|
||||
for namespace in test_namespaces:
|
||||
await store.adelete(namespace, "dummy")
|
||||
|
||||
async def test_search(self):
|
||||
async with AsyncPostgresStore.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"]},
|
||||
]
|
||||
empty = await store.asearch(
|
||||
(
|
||||
"scoped",
|
||||
"assistant_id",
|
||||
"shared",
|
||||
"6c5356f6-63ab-4158-868d-cd9fd14c736e",
|
||||
),
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
assert len(empty) == 0
|
||||
async def test_vector_search_pagination(vector_store: AsyncPostgresStore) -> None:
|
||||
"""Test pagination with vector search."""
|
||||
for i in range(5):
|
||||
await vector_store.aput(
|
||||
("test",), f"doc{i}", {"text": f"test document number {i}"}
|
||||
)
|
||||
|
||||
for namespace, item in zip(test_namespaces, test_items):
|
||||
await store.aput(namespace, f"item_{namespace[-1]}", item)
|
||||
results_page1 = await vector_store.asearch(("test",), query="test", limit=2)
|
||||
results_page2 = await vector_store.asearch(
|
||||
("test",), query="test", limit=2, offset=2
|
||||
)
|
||||
|
||||
docs_result = await store.asearch(["test_search", "documents"])
|
||||
assert len(docs_result) == 2
|
||||
assert all([item.namespace[1] == "documents" for item in docs_result]), [
|
||||
item.namespace for item in docs_result
|
||||
]
|
||||
assert len(results_page1) == 2
|
||||
assert len(results_page2) == 2
|
||||
assert results_page1[0].key != results_page2[0].key
|
||||
|
||||
reports_result = await store.asearch(["test_search", "reports"])
|
||||
assert len(reports_result) == 2
|
||||
assert all(item.namespace[1] == "reports" for item in reports_result)
|
||||
all_results = await vector_store.asearch(("test",), query="test", limit=10)
|
||||
assert len(all_results) == 5
|
||||
|
||||
limited_result = await store.asearch(["test_search"], limit=2)
|
||||
assert len(limited_result) == 2
|
||||
offset_result = await store.asearch(["test_search"])
|
||||
assert len(offset_result) == 4
|
||||
|
||||
offset_result = await store.asearch(["test_search"], offset=2)
|
||||
assert len(offset_result) == 2
|
||||
assert all(item not in limited_result for item in offset_result)
|
||||
async def test_vector_search_edge_cases(vector_store: AsyncPostgresStore) -> None:
|
||||
"""Test edge cases in vector search."""
|
||||
await vector_store.aput(("test",), "doc1", {"text": "test document"})
|
||||
|
||||
john_doe_result = await store.asearch(
|
||||
["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)
|
||||
perfect_match = await vector_store.asearch(("test",), query="text test document")
|
||||
perfect_score = perfect_match[0].score
|
||||
|
||||
draft_result = await store.asearch(
|
||||
["test_search"], filter={"tags": ["draft"]}
|
||||
)
|
||||
assert len(draft_result) == 2
|
||||
assert all("draft" in item.value["tags"] for item in draft_result)
|
||||
results = await vector_store.asearch(("test",), query="")
|
||||
assert len(results) == 1
|
||||
assert results[0].score is None
|
||||
|
||||
page1 = await store.asearch(["test_search"], limit=2, offset=0)
|
||||
page2 = await store.asearch(["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
|
||||
empty = await store.asearch(
|
||||
(
|
||||
"scoped",
|
||||
"assistant_id",
|
||||
"shared",
|
||||
"again",
|
||||
"maybe",
|
||||
"some-long",
|
||||
"6be5cb0e-2eb4-42e6-bb6b-fba3c269db25",
|
||||
),
|
||||
limit=10,
|
||||
offset=0,
|
||||
)
|
||||
assert len(empty) == 0
|
||||
results = await vector_store.asearch(("test",), query=None)
|
||||
assert len(results) == 1
|
||||
assert results[0].score is None
|
||||
|
||||
# Test with a namespace beginning with a number (like a UUID)
|
||||
uuid_namespace = (str(uuid.uuid4()), "documents")
|
||||
uuid_item_id = "uuid_doc"
|
||||
uuid_item_value = {
|
||||
"title": "UUID Document",
|
||||
"content": "This document has a UUID namespace.",
|
||||
}
|
||||
long_query = "foo " * 100
|
||||
results = await vector_store.asearch(("test",), query=long_query)
|
||||
assert len(results) == 1
|
||||
assert results[0].score < perfect_score
|
||||
|
||||
# Insert the item with the UUID namespace
|
||||
await store.aput(uuid_namespace, uuid_item_id, uuid_item_value)
|
||||
special_query = "test!@#$%^&*()"
|
||||
results = await vector_store.asearch(("test",), query=special_query)
|
||||
assert len(results) == 1
|
||||
assert results[0].score < perfect_score
|
||||
|
||||
# Retrieve the item to verify it was stored correctly
|
||||
retrieved_item = await store.aget(uuid_namespace, uuid_item_id)
|
||||
assert retrieved_item is not None
|
||||
assert retrieved_item.namespace == uuid_namespace
|
||||
assert retrieved_item.key == uuid_item_id
|
||||
assert retrieved_item.value == uuid_item_value
|
||||
|
||||
# Search for the item using the UUID namespace
|
||||
search_result = await store.asearch([uuid_namespace[0]])
|
||||
assert len(search_result) == 1
|
||||
assert search_result[0].key == uuid_item_id
|
||||
assert search_result[0].value == uuid_item_value
|
||||
@pytest.mark.parametrize(
|
||||
"vector_type,distance_type",
|
||||
[
|
||||
*itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]),
|
||||
],
|
||||
)
|
||||
async def test_embed_with_path(
|
||||
request: Any,
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
) -> None:
|
||||
"""Test vector search with specific text fields in Postgres store."""
|
||||
async with _create_vector_store(
|
||||
vector_type,
|
||||
distance_type,
|
||||
fake_embeddings,
|
||||
text_fields=["key0", "key1", "key3"],
|
||||
) as store:
|
||||
# This will have 2 vectors representing it
|
||||
doc1 = {
|
||||
# Omit key0 - check it doesn't raise an error
|
||||
"key1": "xxx",
|
||||
"key2": "yyy",
|
||||
"key3": "zzz",
|
||||
}
|
||||
# This will have 3 vectors representing it
|
||||
doc2 = {
|
||||
"key0": "uuu",
|
||||
"key1": "vvv",
|
||||
"key2": "www",
|
||||
"key3": "xxx",
|
||||
}
|
||||
await store.aput(("test",), "doc1", doc1)
|
||||
await store.aput(("test",), "doc2", doc2)
|
||||
|
||||
# Clean up: delete the item with the UUID namespace
|
||||
await store.adelete(uuid_namespace, uuid_item_id)
|
||||
# doc2.key3 and doc1.key1 both would have the highest score
|
||||
results = await store.asearch(("test",), query="xxx")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
ascore = results[0].score
|
||||
bscore = results[1].score
|
||||
assert ascore == pytest.approx(bscore, abs=1e-3)
|
||||
|
||||
# Verify the item was deleted
|
||||
deleted_item = await store.aget(uuid_namespace, uuid_item_id)
|
||||
assert deleted_item is None
|
||||
results = await store.asearch(("test",), query="uuu")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
assert results[0].key == "doc2"
|
||||
assert results[0].score > results[1].score
|
||||
assert ascore == pytest.approx(results[0].score, abs=1e-3)
|
||||
|
||||
for namespace in test_namespaces:
|
||||
await store.adelete(namespace, f"item_{namespace[-1]}")
|
||||
# Un-indexed - will have low results for both. Not zero (because we're projecting)
|
||||
# but less than the above.
|
||||
results = await store.asearch(("test",), query="www")
|
||||
assert len(results) == 2
|
||||
assert results[0].score < ascore
|
||||
assert results[1].score < ascore
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vector_type,distance_type",
|
||||
[
|
||||
*itertools.product(["vector", "halfvec"], ["cosine", "inner_product", "l2"]),
|
||||
],
|
||||
)
|
||||
async def test_search_sorting(
|
||||
request: Any,
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
) -> None:
|
||||
"""Test operation-level field configuration for vector search."""
|
||||
async with _create_vector_store(
|
||||
vector_type,
|
||||
distance_type,
|
||||
fake_embeddings,
|
||||
text_fields=["key1"], # Default fields that won't match our test data
|
||||
) as store:
|
||||
amatch = {
|
||||
"key1": "mmm",
|
||||
}
|
||||
|
||||
await store.aput(("test", "M"), "M", amatch)
|
||||
N = 100
|
||||
for i in range(N):
|
||||
await store.aput(("test", "A"), f"A{i}", {"key1": "no"})
|
||||
for i in range(N):
|
||||
await store.aput(("test", "Z"), f"Z{i}", {"key1": "no"})
|
||||
|
||||
results = await store.asearch(("test",), query="mmm", limit=10)
|
||||
assert len(results) == 10
|
||||
assert len(set(r.key for r in results)) == 10
|
||||
assert results[0].key == "M"
|
||||
assert results[0].score > results[1].score
|
||||
|
||||
@@ -1,174 +1,125 @@
|
||||
# type: ignore
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from conftest import DEFAULT_URI # type: ignore
|
||||
from langchain_core.embeddings import Embeddings
|
||||
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
|
||||
from tests.conftest import (
|
||||
DEFAULT_URI,
|
||||
VECTOR_TYPES,
|
||||
CharacterEmbeddings,
|
||||
)
|
||||
|
||||
|
||||
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 +133,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 +239,459 @@ 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)
|
||||
# Cleanup
|
||||
for namespace, key, _ in test_data:
|
||||
store.delete(namespace, key)
|
||||
|
||||
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]}")
|
||||
@contextmanager
|
||||
def _create_vector_store(
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
fake_embeddings: Embeddings,
|
||||
text_fields: Optional[list[str]] = None,
|
||||
) -> PostgresStore:
|
||||
"""Create a store with vector search enabled."""
|
||||
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
|
||||
|
||||
index_config = {
|
||||
"dims": fake_embeddings.dims,
|
||||
"embed": fake_embeddings,
|
||||
"ann_index_config": {
|
||||
"vector_type": vector_type,
|
||||
},
|
||||
"distance_type": distance_type,
|
||||
"text_fields": text_fields,
|
||||
}
|
||||
|
||||
with Connection.connect(admin_conn_string, autocommit=True) as conn:
|
||||
conn.execute(f"CREATE DATABASE {database}")
|
||||
try:
|
||||
with PostgresStore.from_conn_string(
|
||||
conn_string,
|
||||
index=index_config,
|
||||
) as store:
|
||||
store.setup()
|
||||
yield store
|
||||
finally:
|
||||
with Connection.connect(admin_conn_string, autocommit=True) as conn:
|
||||
conn.execute(f"DROP DATABASE {database}")
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
scope="function",
|
||||
params=[
|
||||
(vector_type, distance_type)
|
||||
for vector_type in VECTOR_TYPES
|
||||
for distance_type in (
|
||||
["hamming"] if vector_type == "bit" else ["l2", "inner_product", "cosine"]
|
||||
)
|
||||
],
|
||||
ids=lambda p: f"{p[0]}_{p[1]}",
|
||||
)
|
||||
def vector_store(
|
||||
request,
|
||||
fake_embeddings: Embeddings,
|
||||
) -> PostgresStore:
|
||||
"""Create a store with vector search enabled."""
|
||||
vector_type, distance_type = request.param
|
||||
with _create_vector_store(vector_type, distance_type, fake_embeddings) as store:
|
||||
yield store
|
||||
|
||||
|
||||
def test_vector_store_initialization(
|
||||
vector_store: PostgresStore, fake_embeddings: CharacterEmbeddings
|
||||
) -> None:
|
||||
"""Test store initialization with embedding config."""
|
||||
# Store should be initialized with embedding config
|
||||
assert vector_store.index_config is not None
|
||||
assert vector_store.index_config["dims"] == fake_embeddings.dims
|
||||
assert vector_store.index_config["embed"] == fake_embeddings
|
||||
|
||||
|
||||
def test_vector_insert_with_auto_embedding(vector_store: PostgresStore) -> None:
|
||||
"""Test inserting items that get auto-embedded."""
|
||||
docs = [
|
||||
("doc1", {"text": "short text"}),
|
||||
("doc2", {"text": "longer text document"}),
|
||||
("doc3", {"text": "longest text document here"}),
|
||||
("doc4", {"description": "text in description field"}),
|
||||
("doc5", {"content": "text in content field"}),
|
||||
("doc6", {"body": "text in body field"}),
|
||||
]
|
||||
|
||||
for key, value in docs:
|
||||
vector_store.put(("test",), key, value)
|
||||
|
||||
results = vector_store.search(("test",), query="long text")
|
||||
assert len(results) > 0
|
||||
|
||||
doc_order = [r.key for r in results]
|
||||
assert "doc2" in doc_order
|
||||
assert "doc3" in doc_order
|
||||
|
||||
|
||||
def test_vector_update_with_embedding(vector_store: PostgresStore) -> None:
|
||||
"""Test that updating items properly updates their embeddings."""
|
||||
vector_store.put(("test",), "doc1", {"text": "zany zebra Xerxes"})
|
||||
vector_store.put(("test",), "doc2", {"text": "something about dogs"})
|
||||
vector_store.put(("test",), "doc3", {"text": "text about birds"})
|
||||
|
||||
results_initial = vector_store.search(("test",), query="Zany Xerxes")
|
||||
assert len(results_initial) > 0
|
||||
assert results_initial[0].key == "doc1"
|
||||
initial_score = results_initial[0].score
|
||||
|
||||
vector_store.put(("test",), "doc1", {"text": "new text about dogs"})
|
||||
|
||||
results_after = vector_store.search(("test",), query="Zany Xerxes")
|
||||
after_score = next((r.score for r in results_after if r.key == "doc1"), 0.0)
|
||||
assert after_score < initial_score
|
||||
|
||||
results_new = vector_store.search(("test",), query="new text about dogs")
|
||||
for r in results_new:
|
||||
if r.key == "doc1":
|
||||
assert r.score > after_score
|
||||
|
||||
# Don't index this one
|
||||
vector_store.put(("test",), "doc4", {"text": "new text about dogs"}, index=False)
|
||||
results_new = vector_store.search(("test",), query="new text about dogs", limit=3)
|
||||
assert not any(r.key == "doc4" for r in results_new)
|
||||
|
||||
|
||||
def test_vector_search_with_filters(vector_store: PostgresStore) -> None:
|
||||
"""Test combining vector search with filters."""
|
||||
# Insert test documents
|
||||
docs = [
|
||||
("doc1", {"text": "red apple", "color": "red", "score": 4.5}),
|
||||
("doc2", {"text": "red car", "color": "red", "score": 3.0}),
|
||||
("doc3", {"text": "green apple", "color": "green", "score": 4.0}),
|
||||
("doc4", {"text": "blue car", "color": "blue", "score": 3.5}),
|
||||
]
|
||||
|
||||
for key, value in docs:
|
||||
vector_store.put(("test",), key, value)
|
||||
|
||||
results = vector_store.search(("test",), query="apple", filter={"color": "red"})
|
||||
assert len(results) == 2
|
||||
assert results[0].key == "doc1"
|
||||
|
||||
results = vector_store.search(("test",), query="car", filter={"color": "red"})
|
||||
assert len(results) == 2
|
||||
assert results[0].key == "doc2"
|
||||
|
||||
results = vector_store.search(
|
||||
("test",), query="bbbbluuu", filter={"score": {"$gt": 3.2}}
|
||||
)
|
||||
assert len(results) == 3
|
||||
assert results[0].key == "doc4"
|
||||
|
||||
# Multiple filters
|
||||
results = vector_store.search(
|
||||
("test",), query="apple", filter={"score": {"$gte": 4.0}, "color": "green"}
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0].key == "doc3"
|
||||
|
||||
|
||||
def test_vector_search_pagination(vector_store: PostgresStore) -> None:
|
||||
"""Test pagination with vector search."""
|
||||
# Insert multiple similar documents
|
||||
for i in range(5):
|
||||
vector_store.put(("test",), f"doc{i}", {"text": f"test document number {i}"})
|
||||
|
||||
# Test with different page sizes
|
||||
results_page1 = vector_store.search(("test",), query="test", limit=2)
|
||||
results_page2 = vector_store.search(("test",), query="test", limit=2, offset=2)
|
||||
|
||||
assert len(results_page1) == 2
|
||||
assert len(results_page2) == 2
|
||||
assert results_page1[0].key != results_page2[0].key
|
||||
|
||||
# Get all results
|
||||
all_results = vector_store.search(("test",), query="test", limit=10)
|
||||
assert len(all_results) == 5
|
||||
|
||||
|
||||
def test_vector_search_edge_cases(vector_store: PostgresStore) -> None:
|
||||
"""Test edge cases in vector search."""
|
||||
vector_store.put(("test",), "doc1", {"text": "test document"})
|
||||
|
||||
results = vector_store.search(("test",), query="")
|
||||
assert len(results) == 1
|
||||
|
||||
results = vector_store.search(("test",), query=None)
|
||||
assert len(results) == 1
|
||||
|
||||
long_query = "test " * 100
|
||||
results = vector_store.search(("test",), query=long_query)
|
||||
assert len(results) == 1
|
||||
|
||||
special_query = "test!@#$%^&*()"
|
||||
results = vector_store.search(("test",), query=special_query)
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vector_type,distance_type",
|
||||
[
|
||||
("vector", "cosine"),
|
||||
("vector", "inner_product"),
|
||||
("halfvec", "cosine"),
|
||||
("halfvec", "inner_product"),
|
||||
],
|
||||
)
|
||||
def test_embed_with_path_sync(
|
||||
request: Any,
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
) -> None:
|
||||
"""Test vector search with specific text fields in Postgres store."""
|
||||
with _create_vector_store(
|
||||
vector_type,
|
||||
distance_type,
|
||||
fake_embeddings,
|
||||
text_fields=["key0", "key1", "key3"],
|
||||
) as store:
|
||||
# This will have 2 vectors representing it
|
||||
doc1 = {
|
||||
# Omit key0 - check it doesn't raise an error
|
||||
"key1": "xxx",
|
||||
"key2": "yyy",
|
||||
"key3": "zzz",
|
||||
}
|
||||
# This will have 3 vectors representing it
|
||||
doc2 = {
|
||||
"key0": "uuu",
|
||||
"key1": "vvv",
|
||||
"key2": "www",
|
||||
"key3": "xxx",
|
||||
}
|
||||
store.put(("test",), "doc1", doc1)
|
||||
store.put(("test",), "doc2", doc2)
|
||||
|
||||
# doc2.key3 and doc1.key1 both would have the highest score
|
||||
results = store.search(("test",), query="xxx")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
ascore = results[0].score
|
||||
bscore = results[1].score
|
||||
assert ascore == pytest.approx(bscore, abs=1e-3)
|
||||
|
||||
# ~Only match doc2
|
||||
results = store.search(("test",), query="uuu")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
assert results[0].key == "doc2"
|
||||
assert results[0].score > results[1].score
|
||||
assert ascore == pytest.approx(results[0].score, abs=1e-3)
|
||||
|
||||
# ~Only match doc1
|
||||
results = store.search(("test",), query="zzz")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
assert results[0].key == "doc1"
|
||||
assert results[0].score > results[1].score
|
||||
assert ascore == pytest.approx(results[0].score, abs=1e-3)
|
||||
|
||||
# Un-indexed - will have low results for both. Not zero (because we're projecting)
|
||||
# but less than the above.
|
||||
results = store.search(("test",), query="www")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
assert results[0].score < ascore
|
||||
assert results[1].score < ascore
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vector_type,distance_type",
|
||||
[
|
||||
("vector", "cosine"),
|
||||
("vector", "inner_product"),
|
||||
("halfvec", "cosine"),
|
||||
("halfvec", "inner_product"),
|
||||
],
|
||||
)
|
||||
def test_embed_with_path_operation_config(
|
||||
request: Any,
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
vector_type: str,
|
||||
distance_type: str,
|
||||
) -> None:
|
||||
"""Test operation-level field configuration for vector search."""
|
||||
with _create_vector_store(
|
||||
vector_type,
|
||||
distance_type,
|
||||
fake_embeddings,
|
||||
text_fields=["key17"], # Default fields that won't match our test data
|
||||
) as store:
|
||||
doc3 = {
|
||||
"key0": "aaa",
|
||||
"key1": "bbb",
|
||||
"key2": "ccc",
|
||||
"key3": "ddd",
|
||||
}
|
||||
doc4 = {
|
||||
"key0": "eee",
|
||||
"key1": "bbb", # Same as doc3.key1
|
||||
"key2": "fff",
|
||||
"key3": "ggg",
|
||||
}
|
||||
|
||||
store.put(("test",), "doc3", doc3, index=["key0", "key1"])
|
||||
store.put(("test",), "doc4", doc4, index=["key1", "key3"])
|
||||
|
||||
results = store.search(("test",), query="aaa")
|
||||
assert len(results) == 2
|
||||
assert results[0].key == "doc3"
|
||||
assert len(set(r.key for r in results)) == 2
|
||||
assert results[0].score > results[1].score
|
||||
|
||||
results = store.search(("test",), query="ggg")
|
||||
assert len(results) == 2
|
||||
assert results[0].key == "doc4"
|
||||
assert results[0].score > results[1].score
|
||||
|
||||
results = store.search(("test",), query="bbb")
|
||||
assert len(results) == 2
|
||||
assert results[0].key != results[1].key
|
||||
assert results[0].score == pytest.approx(results[1].score, abs=1e-3)
|
||||
|
||||
results = store.search(("test",), query="ccc")
|
||||
assert len(results) == 2
|
||||
assert all(
|
||||
r.score < 0.9 for r in results
|
||||
) # Unindexed field should have low scores
|
||||
|
||||
# Test index=False behavior
|
||||
doc5 = {
|
||||
"key0": "hhh",
|
||||
"key1": "iii",
|
||||
}
|
||||
store.put(("test",), "doc5", doc5, index=False)
|
||||
results = store.search(("test",))
|
||||
assert len(results) == 3
|
||||
assert all(r.score is None for r in results)
|
||||
assert any(r.key == "doc5" for r in results)
|
||||
|
||||
results = store.search(("test",), query="hhh")
|
||||
# TODO: We don't currently fill in additional results if there are not enough
|
||||
# returned during vector search.
|
||||
# assert len(results) == 3
|
||||
# doc5_result = next(r for r in results if r.key == "doc5")
|
||||
# assert doc5_result.score is None
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from conftest import DEFAULT_URI # type: ignore
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
@@ -11,6 +10,7 @@ from langgraph.checkpoint.base import (
|
||||
empty_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
from tests.conftest import DEFAULT_URI
|
||||
|
||||
|
||||
class TestPostgresSaver:
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
TEST ?= .
|
||||
|
||||
test:
|
||||
poetry run pytest tests
|
||||
poetry run pytest $(TEST)
|
||||
|
||||
test_watch:
|
||||
poetry run ptw .
|
||||
poetry run ptw $(TEST)
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"""Base classes and types for persistent key-value stores.
|
||||
|
||||
Stores enable persistence and memory that can be shared across threads,
|
||||
scoped to user IDs, assistant IDs, or other arbitrary namespaces.
|
||||
Stores provide long-term memory that persists across threads and conversations.
|
||||
Supports hierarchical namespaces, key-value storage, and optional vector search.
|
||||
|
||||
Core types:
|
||||
- BaseStore: Store interface with sync/async operations
|
||||
- Item: Stored key-value pairs with metadata
|
||||
- Op: Get/Put/Search/List operations
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, Literal, NamedTuple, Optional, Union, cast
|
||||
from typing import Any, Iterable, Literal, NamedTuple, Optional, TypedDict, Union, cast
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
from langgraph.store.base.embed import (
|
||||
AEmbeddingsFunc,
|
||||
EmbeddingsFunc,
|
||||
ensure_embeddings,
|
||||
get_text_at_path,
|
||||
tokenize_path,
|
||||
)
|
||||
|
||||
|
||||
class Item:
|
||||
@@ -73,112 +88,415 @@ class Item:
|
||||
}
|
||||
|
||||
|
||||
class SearchItem(Item):
|
||||
"""Represents a result item with additional response metadata."""
|
||||
|
||||
__slots__ = ("score",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
created_at: datetime,
|
||||
updated_at: datetime,
|
||||
score: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Initialize a result item.
|
||||
|
||||
Args:
|
||||
namespace: Hierarchical path to the item.
|
||||
key: Unique identifier within the namespace.
|
||||
value: The stored value.
|
||||
created_at: When the item was first created.
|
||||
updated_at: When the item was last updated.
|
||||
score: Relevance/similarity score if from a ranked operation.
|
||||
"""
|
||||
super().__init__(
|
||||
value=value,
|
||||
key=key,
|
||||
namespace=namespace,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
self.score = score
|
||||
|
||||
def dict(self) -> dict:
|
||||
result = super().dict()
|
||||
result["score"] = self.score
|
||||
return result
|
||||
|
||||
|
||||
class GetOp(NamedTuple):
|
||||
"""Operation to retrieve an item by namespace and key."""
|
||||
"""Operation to retrieve a specific item by its namespace and key.
|
||||
|
||||
This operation allows precise retrieval of stored items using their full path
|
||||
(namespace) and unique identifier (key) combination.
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
Basic item retrieval:
|
||||
```python
|
||||
GetOp(namespace=("users", "profiles"), key="user123")
|
||||
GetOp(namespace=("cache", "embeddings"), key="doc456")
|
||||
```
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path for the item."""
|
||||
"""Hierarchical path that uniquely identifies the item's location.
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
```python
|
||||
("users",) # Root level users namespace
|
||||
("users", "profiles") # Profiles within users namespace
|
||||
```
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier within the namespace."""
|
||||
"""Unique identifier for the item within its specific namespace.
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
```python
|
||||
"user123" # For a user profile
|
||||
"doc456" # For a document
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
class SearchOp(NamedTuple):
|
||||
"""Operation to search for items within a namespace prefix."""
|
||||
"""Operation to search for items within a specified namespace hierarchy.
|
||||
|
||||
This operation supports both structured filtering and natural language search
|
||||
within a given namespace prefix. It provides pagination through limit and offset
|
||||
parameters.
|
||||
|
||||
Note:
|
||||
Natural language search support depends on your store implementation.
|
||||
|
||||
??? example "Examples"
|
||||
Search with filters and pagination:
|
||||
```python
|
||||
SearchOp(
|
||||
namespace_prefix=("documents",),
|
||||
filter={"type": "report", "status": "active"},
|
||||
limit=5,
|
||||
offset=10
|
||||
)
|
||||
```
|
||||
|
||||
Natural language search:
|
||||
```python
|
||||
SearchOp(
|
||||
namespace_prefix=("users", "content"),
|
||||
query="technical documentation about APIs",
|
||||
limit=20
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
namespace_prefix: tuple[str, ...]
|
||||
"""Hierarchical path prefix to search within."""
|
||||
"""Hierarchical path prefix defining the search scope.
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
```python
|
||||
() # Search entire store
|
||||
("documents",) # Search all documents
|
||||
("users", "content") # Search within user content
|
||||
```
|
||||
"""
|
||||
|
||||
filter: Optional[dict[str, Any]] = None
|
||||
"""Key-value pairs to filter results."""
|
||||
"""Key-value pairs for filtering results based on exact matches or comparison operators.
|
||||
|
||||
The filter supports both exact matches and operator-based comparisons.
|
||||
|
||||
Supported Operators:
|
||||
- $eq: Equal to (same as direct value comparison)
|
||||
- $ne: Not equal to
|
||||
- $gt: Greater than
|
||||
- $gte: Greater than or equal to
|
||||
- $lt: Less than
|
||||
- $lte: Less than or equal to
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
Simple exact match:
|
||||
|
||||
```python
|
||||
{"status": "active"}
|
||||
```
|
||||
|
||||
Comparison operators:
|
||||
|
||||
```python
|
||||
{"score": {"$gt": 4.99}} # Score greater than 4.99
|
||||
```
|
||||
|
||||
Multiple conditions:
|
||||
|
||||
```python
|
||||
{
|
||||
"score": {"$gte": 3.0},
|
||||
"color": "red"
|
||||
}
|
||||
```
|
||||
|
||||
Note:
|
||||
Comparison operator support depends on your store implementation.
|
||||
"""
|
||||
|
||||
limit: int = 10
|
||||
"""Maximum number of items to return."""
|
||||
"""Maximum number of items to return in the search results."""
|
||||
|
||||
offset: int = 0
|
||||
"""Number of items to skip before returning results."""
|
||||
"""Number of matching items to skip for pagination."""
|
||||
|
||||
query: Optional[str] = None
|
||||
"""Natural language search query for semantic search capabilities.
|
||||
|
||||
class PutOp(NamedTuple):
|
||||
"""Operation to store, update, or delete an item."""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path for the item.
|
||||
|
||||
Represented as a tuple of strings, allowing for nested categorization.
|
||||
For example: ("documents", "user123")
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the document.
|
||||
|
||||
Should be distinct within its namespace.
|
||||
"""
|
||||
|
||||
value: Optional[dict[str, Any]]
|
||||
"""Data to be stored, or None to delete the item.
|
||||
|
||||
Schema:
|
||||
- Should be a dictionary where:
|
||||
- Keys are strings representing field names
|
||||
- Values can be of any serializable type
|
||||
- If None, it indicates that the item should be deleted
|
||||
??? example "Examples"
|
||||
- "technical documentation about REST APIs"
|
||||
- "machine learning papers from 2023"
|
||||
"""
|
||||
|
||||
|
||||
NameSpacePath = tuple[Union[str, Literal["*"]], ...]
|
||||
# Type representing a namespace path that can include wildcards
|
||||
NamespacePath = tuple[Union[str, Literal["*"]], ...]
|
||||
"""A tuple representing a namespace path that can include wildcards.
|
||||
|
||||
Examples:
|
||||
("users",) # Exact users namespace
|
||||
("documents", "*") # Any sub-namespace under documents
|
||||
("cache", "*", "v1") # Any cache category with v1 version
|
||||
"""
|
||||
|
||||
# Type for specifying how to match namespaces
|
||||
NamespaceMatchType = Literal["prefix", "suffix"]
|
||||
"""Specifies how to match namespace paths.
|
||||
|
||||
Values:
|
||||
"prefix": Match from the start of the namespace
|
||||
"suffix": Match from the end of the namespace
|
||||
"""
|
||||
|
||||
|
||||
class MatchCondition(NamedTuple):
|
||||
"""Represents a single match condition."""
|
||||
"""Represents a pattern for matching namespaces in the store.
|
||||
|
||||
This class combines a match type (prefix or suffix) with a namespace path
|
||||
pattern that can include wildcards to flexibly match different namespace
|
||||
hierarchies.
|
||||
|
||||
??? example "Examples"
|
||||
Prefix matching:
|
||||
```python
|
||||
MatchCondition(match_type="prefix", path=("users", "profiles"))
|
||||
```
|
||||
|
||||
Suffix matching with wildcard:
|
||||
```python
|
||||
MatchCondition(match_type="suffix", path=("cache", "*"))
|
||||
```
|
||||
|
||||
Simple suffix matching:
|
||||
```python
|
||||
MatchCondition(match_type="suffix", path=("v1",))
|
||||
```
|
||||
"""
|
||||
|
||||
match_type: NamespaceMatchType
|
||||
path: NameSpacePath
|
||||
"""Type of namespace matching to perform."""
|
||||
|
||||
path: NamespacePath
|
||||
"""Namespace path pattern that can include wildcards."""
|
||||
|
||||
|
||||
class ListNamespacesOp(NamedTuple):
|
||||
"""Operation to list namespaces with optional match conditions."""
|
||||
"""Operation to list and filter namespaces in the store.
|
||||
|
||||
This operation allows exploring the organization of data, finding specific
|
||||
collections, and navigating the namespace hierarchy.
|
||||
|
||||
??? example "Examples"
|
||||
|
||||
List all namespaces under the "documents" path:
|
||||
```python
|
||||
ListNamespacesOp(
|
||||
match_conditions=(MatchCondition(match_type="prefix", path=("documents",)),),
|
||||
max_depth=2
|
||||
)
|
||||
```
|
||||
|
||||
List all namespaces that end with "v1":
|
||||
```python
|
||||
ListNamespacesOp(
|
||||
match_conditions=(MatchCondition(match_type="suffix", path=("v1",)),),
|
||||
limit=50
|
||||
)
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
match_conditions: Optional[tuple[MatchCondition, ...]] = None
|
||||
"""A tuple of match conditions to apply to namespaces."""
|
||||
"""Optional conditions for filtering namespaces.
|
||||
|
||||
??? example "Examples"
|
||||
All user namespaces:
|
||||
```python
|
||||
(MatchCondition(match_type="prefix", path=("users",)),)
|
||||
```
|
||||
|
||||
All namespaces that start with "docs" and end with "draft":
|
||||
```python
|
||||
(
|
||||
MatchCondition(match_type="prefix", path=("docs",)),
|
||||
MatchCondition(match_type="suffix", path=("draft",))
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
max_depth: Optional[int] = None
|
||||
"""Return namespaces up to this depth in the hierarchy."""
|
||||
"""Maximum depth of namespace hierarchy to return.
|
||||
|
||||
Note:
|
||||
Namespaces deeper than this level will be truncated.
|
||||
"""
|
||||
|
||||
limit: int = 100
|
||||
"""Maximum number of namespaces to return."""
|
||||
|
||||
offset: int = 0
|
||||
"""Number of namespaces to skip before returning results."""
|
||||
"""Number of namespaces to skip for pagination."""
|
||||
|
||||
|
||||
class PutOp(NamedTuple):
|
||||
"""Operation to store, update, or delete an item in the store.
|
||||
|
||||
This class represents a single operation to modify the store's contents,
|
||||
whether adding new items, updating existing ones, or removing them.
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that identifies the location of the item.
|
||||
|
||||
The namespace acts as a folder-like structure to organize items.
|
||||
Each element in the tuple represents one level in the hierarchy.
|
||||
|
||||
??? example "Examples"
|
||||
Root level documents
|
||||
```python
|
||||
("documents",)
|
||||
```
|
||||
|
||||
User-specific documents
|
||||
```python
|
||||
("documents", "user123")
|
||||
```
|
||||
|
||||
Nested cache structure
|
||||
```python
|
||||
("cache", "embeddings", "v1")
|
||||
```
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its namespace.
|
||||
|
||||
The key must be unique within the specific namespace to avoid conflicts.
|
||||
Together with the namespace, it forms a complete path to the item.
|
||||
|
||||
Example:
|
||||
If namespace is ("documents", "user123") and key is "report1",
|
||||
the full path would effectively be "documents/user123/report1"
|
||||
"""
|
||||
|
||||
value: Optional[dict[str, Any]]
|
||||
"""The data to store, or None to mark the item for deletion.
|
||||
|
||||
The value must be a dictionary with string keys and JSON-serializable values.
|
||||
Setting this to None signals that the item should be deleted.
|
||||
|
||||
Example:
|
||||
{
|
||||
"field1": "string value",
|
||||
"field2": 123,
|
||||
"nested": {"can": "contain", "any": "serializable data"}
|
||||
}
|
||||
"""
|
||||
|
||||
index: Optional[Union[Literal[False], list[str]]] = None # type: ignore[assignment]
|
||||
"""Controls how the item's fields are indexed for search operations.
|
||||
|
||||
Indexing configuration determines how the item can be found through search:
|
||||
- None (default): Uses the store's default indexing configuration (if provided)
|
||||
- False: Disables indexing for this item
|
||||
- list[str]: Specifies which json path fields to index for search
|
||||
|
||||
The item remains accessible through direct get() operations regardless of indexing.
|
||||
When indexed, fields can be searched using natural language queries through
|
||||
vector similarity search (if supported by the store implementation).
|
||||
|
||||
Path Syntax:
|
||||
- Simple field access: "field"
|
||||
- Nested fields: "parent.child.grandchild"
|
||||
- Array indexing:
|
||||
- Specific index: "array[0]"
|
||||
- Last element: "array[-1]"
|
||||
- All elements (each individually): "array[*]"
|
||||
|
||||
??? example "Examples"
|
||||
- None - Use store defaults
|
||||
- False - Don't index this item
|
||||
- list[str] - List of fields to index
|
||||
|
||||
```python
|
||||
[
|
||||
"metadata.title", # Nested field access
|
||||
"chapters[*].content", # Index content from all chapters as separate vectors
|
||||
"authors[0].name", # First author's name
|
||||
"revisions[-1].changes", # Most recent revision's changes
|
||||
"sections[*].paragraphs[*].text", # All text from all paragraphs in all sections
|
||||
"metadata.tags[*]", # All tags in metadata
|
||||
]
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
Op = Union[GetOp, SearchOp, PutOp, ListNamespacesOp]
|
||||
Result = Union[Item, list[Item], list[tuple[str, ...]], None]
|
||||
Result = Union[Item, list[Item], list[SearchItem], list[tuple[str, ...]], None]
|
||||
|
||||
|
||||
class InvalidNamespaceError(ValueError):
|
||||
"""Provided namespace is invalid."""
|
||||
|
||||
|
||||
def _validate_namespace(namespace: tuple[str, ...]) -> None:
|
||||
if not namespace:
|
||||
raise InvalidNamespaceError("Namespace cannot be empty.")
|
||||
for label in namespace:
|
||||
if not isinstance(label, str):
|
||||
raise InvalidNamespaceError(
|
||||
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels"
|
||||
f" must be strings, but got {type(label).__name__}."
|
||||
)
|
||||
if "." in label:
|
||||
raise InvalidNamespaceError(
|
||||
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain periods ('.')."
|
||||
)
|
||||
elif not label:
|
||||
raise InvalidNamespaceError(
|
||||
f"Namespace labels cannot be empty strings. Got {label} in {namespace}"
|
||||
)
|
||||
if namespace[0] == "langgraph":
|
||||
raise InvalidNamespaceError(
|
||||
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
|
||||
)
|
||||
class IndexConfig(TypedDict, total=False):
|
||||
"""Configuration for indexing documents for semantic search in the store."""
|
||||
|
||||
dims: int
|
||||
"""Number of dimensions in the embedding vectors.
|
||||
|
||||
Common embedding models have the following dimensions:
|
||||
- OpenAI text-embedding-3-large: 256, 1024, or 3072
|
||||
- OpenAI text-embedding-3-small: 512 or 1536
|
||||
- OpenAI text-embedding-ada-002: 1536
|
||||
- Cohere embed-english-v3.0: 1024
|
||||
- Cohere embed-english-light-v3.0: 384
|
||||
- Cohere embed-multilingual-v3.0: 1024
|
||||
- Cohere embed-multilingual-light-v3.0: 384
|
||||
"""
|
||||
|
||||
embed: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc]
|
||||
"""Optional function to generate embeddings from text."""
|
||||
|
||||
fields: Optional[list[str]]
|
||||
"""Fields to extract text from for embedding generation.
|
||||
|
||||
Defaults to the root ["$"], which embeds the json object as a whole.
|
||||
"""
|
||||
|
||||
|
||||
class BaseStore(ABC):
|
||||
@@ -231,14 +549,16 @@ class BaseStore(ABC):
|
||||
namespace_prefix: tuple[str, ...],
|
||||
/,
|
||||
*,
|
||||
query: Optional[str] = None,
|
||||
filter: Optional[dict[str, Any]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[Item]:
|
||||
) -> list[SearchItem]:
|
||||
"""Search for items within a namespace prefix.
|
||||
|
||||
Args:
|
||||
namespace_prefix: Hierarchical path prefix to search within.
|
||||
query: Optional query for natural language search.
|
||||
filter: Key-value pairs to filter results.
|
||||
limit: Maximum number of items to return.
|
||||
offset: Number of items to skip before returning results.
|
||||
@@ -246,18 +566,54 @@ class BaseStore(ABC):
|
||||
Returns:
|
||||
List of items matching the search criteria.
|
||||
"""
|
||||
return self.batch([SearchOp(namespace_prefix, filter, limit, offset)])[0]
|
||||
return self.batch([SearchOp(namespace_prefix, filter, limit, offset, query)])[0]
|
||||
|
||||
def put(self, namespace: tuple[str, ...], key: str, value: dict[str, Any]) -> None:
|
||||
"""Store or update an item.
|
||||
def put(
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
index: Optional[Union[Literal[False], list[str]]] = None,
|
||||
) -> None:
|
||||
"""Store or update an item in the store.
|
||||
|
||||
Args:
|
||||
namespace: Hierarchical path for the item.
|
||||
key: Unique identifier within the namespace.
|
||||
value: Dictionary containing the item's data.
|
||||
namespace: Hierarchical path for the item, represented as a tuple of strings.
|
||||
Example: ("documents", "user123")
|
||||
key: Unique identifier within the namespace. Together with namespace forms
|
||||
the complete path to the item.
|
||||
value: Dictionary containing the item's data. Must contain string keys
|
||||
and JSON-serializable values.
|
||||
index: Controls how the item's fields are indexed for search:
|
||||
- None (default): Use store's default indexing configuration
|
||||
- False: Disable indexing for this item
|
||||
- list[str]: List of field paths to index, supporting:
|
||||
- Nested fields: "metadata.title"
|
||||
- Array access: "chapters[*].content" (each indexed separately)
|
||||
- Specific indices: "authors[0].name"
|
||||
|
||||
Note:
|
||||
Indexing capabilities depend on your store implementation.
|
||||
Some implementations may support only a subset of indexing features.
|
||||
|
||||
??? example "Examples"
|
||||
Simple storage without special indexing (respects store defaults)
|
||||
```python
|
||||
store.put(("docs",), "report", {"title": "Annual Report"})
|
||||
```
|
||||
|
||||
Index specific fields for search
|
||||
```python
|
||||
store.put(("docs",), "report", {"title": "Annual Report"}, index=["title"])
|
||||
```
|
||||
|
||||
Do not index for semantic search
|
||||
```python
|
||||
store.put(("docs",), "report", {"title": "Annual Report"}, index=False)
|
||||
```
|
||||
"""
|
||||
_validate_namespace(namespace)
|
||||
self.batch([PutOp(namespace, key, value)])
|
||||
self.batch([PutOp(namespace, key, value, index=index)])
|
||||
|
||||
def delete(self, namespace: tuple[str, ...], key: str) -> None:
|
||||
"""Delete an item.
|
||||
@@ -271,8 +627,8 @@ class BaseStore(ABC):
|
||||
def list_namespaces(
|
||||
self,
|
||||
*,
|
||||
prefix: Optional[NameSpacePath] = None,
|
||||
suffix: Optional[NameSpacePath] = None,
|
||||
prefix: Optional[NamespacePath] = None,
|
||||
suffix: Optional[NamespacePath] = None,
|
||||
max_depth: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -286,7 +642,7 @@ class BaseStore(ABC):
|
||||
prefix (Optional[Tuple[str, ...]]): Filter namespaces that start with this path.
|
||||
suffix (Optional[Tuple[str, ...]]): Filter namespaces that end with this path.
|
||||
max_depth (Optional[int]): Return namespaces up to this depth in the hierarchy.
|
||||
Namespaces deeper than this level will be truncated to this depth.
|
||||
Namespaces deeper than this level will be truncated.
|
||||
limit (int): Maximum number of namespaces to return (default 100).
|
||||
offset (int): Number of namespaces to skip for pagination (default 0).
|
||||
|
||||
@@ -294,16 +650,18 @@ class BaseStore(ABC):
|
||||
List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
|
||||
Each tuple represents a full namespace path up to `max_depth`.
|
||||
|
||||
Examples:
|
||||
|
||||
??? example "Examples":
|
||||
Setting max_depth=3. Given the namespaces:
|
||||
# ("a", "b", "c")
|
||||
# ("a", "b", "d", "e")
|
||||
# ("a", "b", "d", "i")
|
||||
# ("a", "b", "f")
|
||||
# ("a", "c", "f")
|
||||
store.list_namespaces(prefix=("a", "b"), max_depth=3)
|
||||
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
|
||||
```python
|
||||
# Example if you have the following namespaces:
|
||||
# ("a", "b", "c")
|
||||
# ("a", "b", "d", "e")
|
||||
# ("a", "b", "d", "i")
|
||||
# ("a", "b", "f")
|
||||
# ("a", "c", "f")
|
||||
store.list_namespaces(prefix=("a", "b"), max_depth=3)
|
||||
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
|
||||
```
|
||||
"""
|
||||
match_conditions = []
|
||||
if prefix:
|
||||
@@ -336,14 +694,16 @@ class BaseStore(ABC):
|
||||
namespace_prefix: tuple[str, ...],
|
||||
/,
|
||||
*,
|
||||
query: Optional[str] = None,
|
||||
filter: Optional[dict[str, Any]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[Item]:
|
||||
) -> list[SearchItem]:
|
||||
"""Asynchronously search for items within a namespace prefix.
|
||||
|
||||
Args:
|
||||
namespace_prefix: Hierarchical path prefix to search within.
|
||||
query: Optional query for natural language search.
|
||||
filter: Key-value pairs to filter results.
|
||||
limit: Maximum number of items to return.
|
||||
offset: Number of items to skip before returning results.
|
||||
@@ -351,22 +711,61 @@ class BaseStore(ABC):
|
||||
Returns:
|
||||
List of items matching the search criteria.
|
||||
"""
|
||||
return (await self.abatch([SearchOp(namespace_prefix, filter, limit, offset)]))[
|
||||
0
|
||||
]
|
||||
return (
|
||||
await self.abatch(
|
||||
[SearchOp(namespace_prefix, filter, limit, offset, query)]
|
||||
)
|
||||
)[0]
|
||||
|
||||
async def aput(
|
||||
self, namespace: tuple[str, ...], key: str, value: dict[str, Any]
|
||||
self,
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
index: Optional[Union[Literal[False], list[str]]] = None,
|
||||
) -> None:
|
||||
"""Asynchronously store or update an item.
|
||||
"""Asynchronously store or update an item in the store.
|
||||
|
||||
Args:
|
||||
namespace: Hierarchical path for the item.
|
||||
key: Unique identifier within the namespace.
|
||||
value: Dictionary containing the item's data.
|
||||
namespace: Hierarchical path for the item, represented as a tuple of strings.
|
||||
Example: ("documents", "user123")
|
||||
key: Unique identifier within the namespace. Together with namespace forms
|
||||
the complete path to the item.
|
||||
value: Dictionary containing the item's data. Must contain string keys
|
||||
and JSON-serializable values.
|
||||
index: Controls how the item's fields are indexed for search:
|
||||
- None (default): Use store's default indexing configuration
|
||||
- False: Disable indexing for this item
|
||||
- list[str]: List of field paths to index, supporting:
|
||||
- Nested fields: "metadata.title"
|
||||
- Array access: "chapters[*].content" (each indexed separately)
|
||||
- Specific indices: "authors[0].name"
|
||||
|
||||
Note:
|
||||
Indexing capabilities depend on your store implementation.
|
||||
Some implementations may support only a subset of indexing features.
|
||||
|
||||
??? example "Examples"
|
||||
Simple storage without special indexing:
|
||||
```python
|
||||
await store.aput(("docs",), "report", {"title": "Annual Report"})
|
||||
```
|
||||
|
||||
Index specific fields for search:
|
||||
```python
|
||||
await store.aput(
|
||||
("docs",),
|
||||
"report",
|
||||
{
|
||||
"title": "Q4 Report",
|
||||
"chapters": [{"content": "..."}, {"content": "..."}]
|
||||
},
|
||||
index=["title", "chapters[*].content"]
|
||||
)
|
||||
```
|
||||
"""
|
||||
_validate_namespace(namespace)
|
||||
await self.abatch([PutOp(namespace, key, value)])
|
||||
await self.abatch([PutOp(namespace, key, value, index=index)])
|
||||
|
||||
async def adelete(self, namespace: tuple[str, ...], key: str) -> None:
|
||||
"""Asynchronously delete an item.
|
||||
@@ -380,8 +779,8 @@ class BaseStore(ABC):
|
||||
async def alist_namespaces(
|
||||
self,
|
||||
*,
|
||||
prefix: Optional[NameSpacePath] = None,
|
||||
suffix: Optional[NameSpacePath] = None,
|
||||
prefix: Optional[NamespacePath] = None,
|
||||
suffix: Optional[NamespacePath] = None,
|
||||
max_depth: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -403,16 +802,19 @@ class BaseStore(ABC):
|
||||
List[Tuple[str, ...]]: A list of namespace tuples that match the criteria.
|
||||
Each tuple represents a full namespace path up to `max_depth`.
|
||||
|
||||
Examples:
|
||||
??? example "Examples"
|
||||
Setting max_depth=3 with existing namespaces:
|
||||
```python
|
||||
# Given the following namespaces:
|
||||
# ("a", "b", "c")
|
||||
# ("a", "b", "d", "e")
|
||||
# ("a", "b", "d", "i")
|
||||
# ("a", "b", "f")
|
||||
# ("a", "c", "f")
|
||||
|
||||
Setting max_depth=3. Given the namespaces:
|
||||
# ("a", "b", "c")
|
||||
# ("a", "b", "d", "e")
|
||||
# ("a", "b", "d", "i")
|
||||
# ("a", "b", "f")
|
||||
# ("a", "c", "f")
|
||||
await store.alist_namespaces(prefix=("a", "b"), max_depth=3)
|
||||
# [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
|
||||
await store.alist_namespaces(prefix=("a", "b"), max_depth=3)
|
||||
# Returns: [("a", "b", "c"), ("a", "b", "d"), ("a", "b", "f")]
|
||||
```
|
||||
"""
|
||||
match_conditions = []
|
||||
if prefix:
|
||||
@@ -427,3 +829,44 @@ class BaseStore(ABC):
|
||||
offset=offset,
|
||||
)
|
||||
return (await self.abatch([op]))[0]
|
||||
|
||||
|
||||
def _validate_namespace(namespace: tuple[str, ...]) -> None:
|
||||
if not namespace:
|
||||
raise InvalidNamespaceError("Namespace cannot be empty.")
|
||||
for label in namespace:
|
||||
if not isinstance(label, str):
|
||||
raise InvalidNamespaceError(
|
||||
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels"
|
||||
f" must be strings, but got {type(label).__name__}."
|
||||
)
|
||||
if "." in label:
|
||||
raise InvalidNamespaceError(
|
||||
f"Invalid namespace label '{label}' found in {namespace}. Namespace labels cannot contain periods ('.')."
|
||||
)
|
||||
elif not label:
|
||||
raise InvalidNamespaceError(
|
||||
f"Namespace labels cannot be empty strings. Got {label} in {namespace}"
|
||||
)
|
||||
if namespace[0] == "langgraph":
|
||||
raise InvalidNamespaceError(
|
||||
f'Root label for namespace cannot be "langgraph". Got: {namespace}'
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseStore",
|
||||
"Item",
|
||||
"Op",
|
||||
"PutOp",
|
||||
"GetOp",
|
||||
"SearchOp",
|
||||
"ListNamespacesOp",
|
||||
"MatchCondition",
|
||||
"NamespacePath",
|
||||
"NamespaceMatchType",
|
||||
"Embeddings",
|
||||
"ensure_embeddings",
|
||||
"tokenize_path",
|
||||
"get_text_at_path",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import asyncio
|
||||
import weakref
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
from langgraph.store.base import (
|
||||
BaseStore,
|
||||
GetOp,
|
||||
Item,
|
||||
ListNamespacesOp,
|
||||
MatchCondition,
|
||||
NamespacePath,
|
||||
Op,
|
||||
PutOp,
|
||||
SearchItem,
|
||||
SearchOp,
|
||||
_validate_namespace,
|
||||
)
|
||||
@@ -40,12 +44,13 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
namespace_prefix: tuple[str, ...],
|
||||
/,
|
||||
*,
|
||||
query: Optional[str] = None,
|
||||
filter: Optional[dict[str, Any]] = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
) -> list[Item]:
|
||||
) -> list[SearchItem]:
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue[fut] = SearchOp(namespace_prefix, filter, limit, offset)
|
||||
self._aqueue[fut] = SearchOp(namespace_prefix, filter, limit, offset, query)
|
||||
return await fut
|
||||
|
||||
async def aput(
|
||||
@@ -53,10 +58,11 @@ class AsyncBatchedBaseStore(BaseStore):
|
||||
namespace: tuple[str, ...],
|
||||
key: str,
|
||||
value: dict[str, Any],
|
||||
index: Optional[Union[Literal[False], list[str]]] = None,
|
||||
) -> None:
|
||||
_validate_namespace(namespace)
|
||||
fut = self._loop.create_future()
|
||||
self._aqueue[fut] = PutOp(namespace, key, value)
|
||||
self._aqueue[fut] = PutOp(namespace, key, value, index)
|
||||
return await fut
|
||||
|
||||
async def adelete(
|
||||
@@ -68,6 +74,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 +155,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)
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Utilities for working with embedding functions and LangChain's Embeddings interface.
|
||||
|
||||
This module provides tools to wrap arbitrary embedding functions (both sync and async)
|
||||
into LangChain's Embeddings interface. This enables using custom embedding functions
|
||||
with LangChain-compatible tools while maintaining support for both synchronous and
|
||||
asynchronous operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Awaitable, Callable, Optional, Sequence, Union
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
EmbeddingsFunc = Callable[[Sequence[str]], list[list[float]]]
|
||||
"""Type for synchronous embedding functions.
|
||||
|
||||
The function should take a sequence of strings and return a list of embeddings,
|
||||
where each embedding is a list of floats. The dimensionality of the embeddings
|
||||
should be consistent for all inputs.
|
||||
"""
|
||||
|
||||
AEmbeddingsFunc = Callable[[Sequence[str]], Awaitable[list[list[float]]]]
|
||||
"""Type for asynchronous embedding functions.
|
||||
|
||||
Similar to EmbeddingsFunc, but returns an awaitable that resolves to the embeddings.
|
||||
"""
|
||||
|
||||
|
||||
def ensure_embeddings(
|
||||
embed: Union[Embeddings, EmbeddingsFunc, AEmbeddingsFunc, None],
|
||||
) -> Embeddings:
|
||||
"""Ensure that an embedding function conforms to LangChain's Embeddings interface.
|
||||
|
||||
This function wraps arbitrary embedding functions to make them compatible with
|
||||
LangChain's Embeddings interface. It handles both synchronous and asynchronous
|
||||
functions.
|
||||
|
||||
Args:
|
||||
embed: Either an existing Embeddings instance, or a function that converts
|
||||
text to embeddings. If the function is async, it will be used for both
|
||||
sync and async operations.
|
||||
|
||||
Returns:
|
||||
An Embeddings instance that wraps the provided function(s).
|
||||
|
||||
??? example "Examples"
|
||||
Wrap a synchronous embedding function:
|
||||
```python
|
||||
def my_embed_fn(texts):
|
||||
return [[0.1, 0.2] for _ in texts]
|
||||
|
||||
embeddings = ensure_embeddings(my_embed_fn)
|
||||
result = embeddings.embed_query("hello") # Returns [0.1, 0.2]
|
||||
```
|
||||
|
||||
Wrap an asynchronous embedding function:
|
||||
```python
|
||||
async def my_async_fn(texts):
|
||||
return [[0.1, 0.2] for _ in texts]
|
||||
|
||||
embeddings = ensure_embeddings(my_async_fn)
|
||||
result = await embeddings.aembed_query("hello") # Returns [0.1, 0.2]
|
||||
```
|
||||
"""
|
||||
if embed is None:
|
||||
raise ValueError("embed must be provided")
|
||||
if isinstance(embed, Embeddings):
|
||||
return embed
|
||||
return EmbeddingsLambda(embed)
|
||||
|
||||
|
||||
class EmbeddingsLambda(Embeddings):
|
||||
"""Wrapper to convert embedding functions into LangChain's Embeddings interface.
|
||||
|
||||
This class allows arbitrary embedding functions to be used with LangChain-compatible
|
||||
tools. It supports both synchronous and asynchronous operations, and can handle:
|
||||
1. A synchronous function for sync operations (async operations will use sync function)
|
||||
2. An async function for both sync/async operations (sync operations will raise an error)
|
||||
|
||||
The embedding functions should convert text into fixed-dimensional vectors that
|
||||
capture the semantic meaning of the text.
|
||||
|
||||
Args:
|
||||
func: Function that converts text to embeddings. Can be sync or async.
|
||||
If async, it will be used for async operations, but sync operations
|
||||
will raise an error. If sync, it will be used for both sync and async operations.
|
||||
|
||||
??? example "Examples"
|
||||
With a sync function:
|
||||
```python
|
||||
def my_embed_fn(texts):
|
||||
# Return 2D embeddings for each text
|
||||
return [[0.1, 0.2] for _ in texts]
|
||||
|
||||
embeddings = EmbeddingsLambda(my_embed_fn)
|
||||
result = embeddings.embed_query("hello") # Returns [0.1, 0.2]
|
||||
await embeddings.aembed_query("hello") # Also returns [0.1, 0.2]
|
||||
```
|
||||
|
||||
With an async function:
|
||||
```python
|
||||
async def my_async_fn(texts):
|
||||
return [[0.1, 0.2] for _ in texts]
|
||||
|
||||
embeddings = EmbeddingsLambda(my_async_fn)
|
||||
await embeddings.aembed_query("hello") # Returns [0.1, 0.2]
|
||||
# Note: embed_query() would raise an error
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Union[EmbeddingsFunc, AEmbeddingsFunc],
|
||||
) -> None:
|
||||
if func is None:
|
||||
raise ValueError("func must be provided")
|
||||
if _is_async_callable(func):
|
||||
self.afunc = func
|
||||
else:
|
||||
self.func = func
|
||||
|
||||
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embed a list of texts into vectors.
|
||||
|
||||
Args:
|
||||
texts: list of texts to convert to embeddings.
|
||||
|
||||
Returns:
|
||||
list of embeddings, one per input text. Each embedding is a list of floats.
|
||||
|
||||
Raises:
|
||||
ValueError: If the instance was initialized with only an async function.
|
||||
"""
|
||||
func = getattr(self, "func", None)
|
||||
if func is None:
|
||||
raise ValueError(
|
||||
"EmbeddingsLambda was initialized with an async function but no sync function. "
|
||||
"Use aembed_documents for async operation or provide a sync function."
|
||||
)
|
||||
return func(texts)
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
"""Embed a single piece of text.
|
||||
|
||||
Args:
|
||||
text: Text to convert to an embedding.
|
||||
|
||||
Returns:
|
||||
Embedding vector as a list of floats.
|
||||
|
||||
Note:
|
||||
This is equivalent to calling embed_documents with a single text
|
||||
and taking the first result.
|
||||
"""
|
||||
return self.embed_documents([text])[0]
|
||||
|
||||
async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Asynchronously embed a list of texts into vectors.
|
||||
|
||||
Args:
|
||||
texts: list of texts to convert to embeddings.
|
||||
|
||||
Returns:
|
||||
list of embeddings, one per input text. Each embedding is a list of floats.
|
||||
|
||||
Note:
|
||||
If no async function was provided, this falls back to the sync implementation.
|
||||
"""
|
||||
afunc = getattr(self, "afunc", None)
|
||||
if afunc is None:
|
||||
return await super().aembed_documents(texts)
|
||||
return await afunc(texts)
|
||||
|
||||
async def aembed_query(self, text: str) -> list[float]:
|
||||
"""Asynchronously embed a single piece of text.
|
||||
|
||||
Args:
|
||||
text: Text to convert to an embedding.
|
||||
|
||||
Returns:
|
||||
Embedding vector as a list of floats.
|
||||
|
||||
Note:
|
||||
This is equivalent to calling aembed_documents with a single text
|
||||
and taking the first result.
|
||||
"""
|
||||
afunc = getattr(self, "afunc", None)
|
||||
if afunc is None:
|
||||
return await super().aembed_query(text)
|
||||
return (await afunc([text]))[0]
|
||||
|
||||
|
||||
def get_text_at_path(obj: Any, path: Union[str, list[str]]) -> list[str]:
|
||||
"""Extract text from an object using a path expression or pre-tokenized path.
|
||||
|
||||
Args:
|
||||
obj: The object to extract text from
|
||||
path: Either a path string or pre-tokenized path list.
|
||||
|
||||
!!! info "Path types handled"
|
||||
- Simple paths: "field1.field2"
|
||||
- Array indexing: "[0]", "[*]", "[-1]"
|
||||
- Wildcards: "*"
|
||||
- Multi-field selection: "{field1,field2}"
|
||||
- Nested paths in multi-field: "{field1,nested.field2}"
|
||||
"""
|
||||
if not path or path == "$":
|
||||
return [json.dumps(obj, sort_keys=True)]
|
||||
|
||||
tokens = tokenize_path(path) if isinstance(path, str) else path
|
||||
|
||||
def _extract_from_obj(obj: Any, tokens: list[str], pos: int) -> list[str]:
|
||||
if pos >= len(tokens):
|
||||
if isinstance(obj, (str, int, float, bool)):
|
||||
return [str(obj)]
|
||||
elif obj is None:
|
||||
return []
|
||||
elif isinstance(obj, (list, dict)):
|
||||
return [json.dumps(obj, sort_keys=True)]
|
||||
return []
|
||||
|
||||
token = tokens[pos]
|
||||
results = []
|
||||
|
||||
if token.startswith("[") and token.endswith("]"):
|
||||
if not isinstance(obj, list):
|
||||
return []
|
||||
|
||||
index = token[1:-1]
|
||||
if index == "*":
|
||||
for item in obj:
|
||||
results.extend(_extract_from_obj(item, tokens, pos + 1))
|
||||
else:
|
||||
try:
|
||||
idx = int(index)
|
||||
if idx < 0:
|
||||
idx = len(obj) + idx
|
||||
if 0 <= idx < len(obj):
|
||||
results.extend(_extract_from_obj(obj[idx], tokens, pos + 1))
|
||||
except (ValueError, IndexError):
|
||||
return []
|
||||
|
||||
elif token.startswith("{") and token.endswith("}"):
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
|
||||
fields = [f.strip() for f in token[1:-1].split(",")]
|
||||
for field in fields:
|
||||
nested_tokens = tokenize_path(field)
|
||||
if nested_tokens:
|
||||
current_obj: Optional[dict] = obj
|
||||
for nested_token in nested_tokens:
|
||||
if (
|
||||
isinstance(current_obj, dict)
|
||||
and nested_token in current_obj
|
||||
):
|
||||
current_obj = current_obj[nested_token]
|
||||
else:
|
||||
current_obj = None
|
||||
break
|
||||
if current_obj is not None:
|
||||
if isinstance(current_obj, (str, int, float, bool)):
|
||||
results.append(str(current_obj))
|
||||
elif isinstance(current_obj, (list, dict)):
|
||||
results.append(json.dumps(current_obj, sort_keys=True))
|
||||
|
||||
# Handle wildcard
|
||||
elif token == "*":
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
results.extend(_extract_from_obj(value, tokens, pos + 1))
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
results.extend(_extract_from_obj(item, tokens, pos + 1))
|
||||
|
||||
# Handle regular field
|
||||
else:
|
||||
if isinstance(obj, dict) and token in obj:
|
||||
results.extend(_extract_from_obj(obj[token], tokens, pos + 1))
|
||||
|
||||
return results
|
||||
|
||||
return _extract_from_obj(obj, tokens, 0)
|
||||
|
||||
|
||||
# Private utility functions
|
||||
|
||||
|
||||
def tokenize_path(path: str) -> list[str]:
|
||||
"""Tokenize a path into components.
|
||||
|
||||
!!! info "Types handled"
|
||||
- Simple paths: "field1.field2"
|
||||
- Array indexing: "[0]", "[*]", "[-1]"
|
||||
- Wildcards: "*"
|
||||
- Multi-field selection: "{field1,field2}"
|
||||
"""
|
||||
if not path:
|
||||
return []
|
||||
|
||||
tokens = []
|
||||
current: list[str] = []
|
||||
i = 0
|
||||
while i < len(path):
|
||||
char = path[i]
|
||||
|
||||
if char == "[": # Handle array index
|
||||
if current:
|
||||
tokens.append("".join(current))
|
||||
current = []
|
||||
bracket_count = 1
|
||||
index_chars = ["["]
|
||||
i += 1
|
||||
while i < len(path) and bracket_count > 0:
|
||||
if path[i] == "[":
|
||||
bracket_count += 1
|
||||
elif path[i] == "]":
|
||||
bracket_count -= 1
|
||||
index_chars.append(path[i])
|
||||
i += 1
|
||||
tokens.append("".join(index_chars))
|
||||
continue
|
||||
|
||||
elif char == "{": # Handle multi-field selection
|
||||
if current:
|
||||
tokens.append("".join(current))
|
||||
current = []
|
||||
brace_count = 1
|
||||
field_chars = ["{"]
|
||||
i += 1
|
||||
while i < len(path) and brace_count > 0:
|
||||
if path[i] == "{":
|
||||
brace_count += 1
|
||||
elif path[i] == "}":
|
||||
brace_count -= 1
|
||||
field_chars.append(path[i])
|
||||
i += 1
|
||||
tokens.append("".join(field_chars))
|
||||
continue
|
||||
|
||||
elif char == ".": # Handle regular field
|
||||
if current:
|
||||
tokens.append("".join(current))
|
||||
current = []
|
||||
else:
|
||||
current.append(char)
|
||||
i += 1
|
||||
|
||||
if current:
|
||||
tokens.append("".join(current))
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_async_callable(
|
||||
func: Any,
|
||||
) -> bool:
|
||||
"""Check if a function is async.
|
||||
|
||||
This includes both async def functions and classes with async __call__ methods.
|
||||
|
||||
Args:
|
||||
func: Function or callable object to check.
|
||||
|
||||
Returns:
|
||||
True if the function is async, False otherwise.
|
||||
"""
|
||||
return (
|
||||
asyncio.iscoroutinefunction(func)
|
||||
or hasattr(func, "__call__") # noqa: B004
|
||||
and asyncio.iscoroutinefunction(func.__call__)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ensure_embeddings",
|
||||
"EmbeddingsFunc",
|
||||
"AEmbeddingsFunc",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user