mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-27 01:52:25 +02:00
Compare commits
189
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a215106c | ||
|
|
fa5030dfee | ||
|
|
04ed16fa20 | ||
|
|
2a473c1d6e | ||
|
|
3d36e355a4 | ||
|
|
c0bd2dbc07 | ||
|
|
12ea4c24d5 | ||
|
|
88dfc0a6bb | ||
|
|
cb9ba2bc8d | ||
|
|
90865e2af9 | ||
|
|
e13004da77 | ||
|
|
5a9264d124 | ||
|
|
efa86a6b14 | ||
|
|
f9866186d9 | ||
|
|
bfdd7deb60 | ||
|
|
09c8bb7c1c | ||
|
|
84d59adcd8 | ||
|
|
841ebf0c77 | ||
|
|
4f5b775819 | ||
|
|
76bee17ec4 | ||
|
|
d4335683e6 | ||
|
|
b43107ec99 | ||
|
|
3987f9cd63 | ||
|
|
2bbc3bb1da | ||
|
|
b3474d2db1 | ||
|
|
56e9fe1b10 | ||
|
|
1b3d075dbb | ||
|
|
c2a9661d2a | ||
|
|
9e723642c0 | ||
|
|
7c0a9275f9 | ||
|
|
783b3d3435 | ||
|
|
597b3402e6 | ||
|
|
54b723775c | ||
|
|
93c4a0a2d5 | ||
|
|
a5b90fbb95 | ||
|
|
85272db354 | ||
|
|
d7614999b0 | ||
|
|
af5f74f2ad | ||
|
|
708c0dff7f | ||
|
|
d50801d871 | ||
|
|
0327a86b80 | ||
|
|
13847a32c0 | ||
|
|
03ad7a011c | ||
|
|
896c1a8054 | ||
|
|
388d6b3593 | ||
|
|
667b679694 | ||
|
|
ccb9f4c41a | ||
|
|
789be99634 | ||
|
|
e33842ff54 | ||
|
|
89d37a0f9b | ||
|
|
8f717d3874 | ||
|
|
508193272a | ||
|
|
788ae6cb72 | ||
|
|
b2dca399e8 | ||
|
|
4b2167dd25 | ||
|
|
be46e91180 | ||
|
|
bbb308259b | ||
|
|
14ce607111 | ||
|
|
3330ccdea4 | ||
|
|
4ef61690c6 | ||
|
|
27da1d35ef | ||
|
|
9c2deacb28 | ||
|
|
2638ff715a | ||
|
|
65117979b4 | ||
|
|
6a19a5a7b2 | ||
|
|
f4a18e0409 | ||
|
|
d5b612459c | ||
|
|
f7d94d2b2f | ||
|
|
c084849b43 | ||
|
|
c08917a1e7 | ||
|
|
da6c7a50b8 | ||
|
|
27a0db7065 | ||
|
|
7256520b08 | ||
|
|
95cc381d21 | ||
|
|
46fed9d161 | ||
|
|
e00a027579 | ||
|
|
a3823395cf | ||
|
|
c1e62bad8a | ||
|
|
63841de505 | ||
|
|
0fe365ec4c | ||
|
|
1de3d82598 | ||
|
|
79a75645ca | ||
|
|
cdda595e6e | ||
|
|
7895051c96 | ||
|
|
901ab6b3f8 | ||
|
|
adb953ddd4 | ||
|
|
5ddfce1814 | ||
|
|
1f31e0b9b6 | ||
|
|
1b37ece92f | ||
|
|
e2e90da5dc | ||
|
|
d542d8aecb | ||
|
|
a04ec5d6f0 | ||
|
|
50df7d423a | ||
|
|
c4a4a46473 | ||
|
|
f178eb821e | ||
|
|
48167d7fec | ||
|
|
806878a421 | ||
|
|
8087e6a42c | ||
|
|
8fbdb14487 | ||
|
|
5093802f31 | ||
|
|
b89ef60b91 | ||
|
|
672da815a3 | ||
|
|
b704e41632 | ||
|
|
ea7906b177 | ||
|
|
ef7897e12e | ||
|
|
e9fed2798e | ||
|
|
ed293f16d6 | ||
|
|
f702729e04 | ||
|
|
b0f14649e0 | ||
|
|
ea20432b9b | ||
|
|
e2efab8061 | ||
|
|
9babffa054 | ||
|
|
73cebea3c2 | ||
|
|
b73b2d19eb | ||
|
|
ca26805b5f | ||
|
|
5ac837d7cd | ||
|
|
c4f5861166 | ||
|
|
172238b2d5 | ||
|
|
095da17833 | ||
|
|
e931c68669 | ||
|
|
666c224c2d | ||
|
|
21a6f41e0a | ||
|
|
acdf85aba6 | ||
|
|
9b18243fa6 | ||
|
|
0225b998af | ||
|
|
bec122d4a2 | ||
|
|
2b416f6f47 | ||
|
|
9b9de5bd16 | ||
|
|
3b04ee4677 | ||
|
|
e80b3136ad | ||
|
|
4eb1766b58 | ||
|
|
a76cf88232 | ||
|
|
bd6da75a85 | ||
|
|
7889a907e5 | ||
|
|
762b8f8579 | ||
|
|
83fcca8687 | ||
|
|
5da9a1d844 | ||
|
|
17b3285907 | ||
|
|
20570cf700 | ||
|
|
df94475d3a | ||
|
|
270621db66 | ||
|
|
a181e0bb91 | ||
|
|
b233201308 | ||
|
|
443cee2fb3 | ||
|
|
d280bca8da | ||
|
|
3701fa4806 | ||
|
|
72be9b23ee | ||
|
|
52bbd34673 | ||
|
|
7216504ce2 | ||
|
|
fe4daa1c7c | ||
|
|
34769f31bc | ||
|
|
eac6abb8ee | ||
|
|
9f0ae94f27 | ||
|
|
f5e56e200d | ||
|
|
f9870bc9ae | ||
|
|
a734f5e6ce | ||
|
|
84446f5ad8 | ||
|
|
f6d95abbe3 | ||
|
|
a7a27dd43a | ||
|
|
50238be239 | ||
|
|
114978b612 | ||
|
|
0c0a159539 | ||
|
|
f688b068e7 | ||
|
|
1fb405bd55 | ||
|
|
86b65beb8f | ||
|
|
63bd852da9 | ||
|
|
82f9c09b95 | ||
|
|
193e128c20 | ||
|
|
c94e7b96ac | ||
|
|
2dd39432a3 | ||
|
|
3ff6340379 | ||
|
|
0a6145fd72 | ||
|
|
fbcb8a911b | ||
|
|
2c6f99cbf0 | ||
|
|
7b9ff6129b | ||
|
|
c1b3598ca8 | ||
|
|
30355a7a5d | ||
|
|
b0c6126f2a | ||
|
|
0cab88c7dd | ||
|
|
8cb87eaf76 | ||
|
|
089cdd0ffb | ||
|
|
fb0091fd57 | ||
|
|
e820097701 | ||
|
|
f91d79d0c8 | ||
|
|
cb2faa7dda | ||
|
|
a5827c5c61 | ||
|
|
5212369bd0 | ||
|
|
7045a23148 | ||
|
|
728db10b1f |
@@ -1,6 +0,0 @@
|
||||
# Contributing to LangGraph
|
||||
|
||||
Hi there! Thank you for even being interested in contributing to LangGraph.
|
||||
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether they involve new features, improved infrastructure, better documentation, or bug fixes.
|
||||
|
||||
To learn how to contribute to LangGraph, please follow the [contribution guide here](https://docs.langchain.com/oss/python/contributing).
|
||||
@@ -1,43 +1,60 @@
|
||||
name: "\U0001F41B Bug Report"
|
||||
description: Report a bug in LangGraph. To report a security issue, please instead use the security option below. For questions, please use the LangChain Forum at forum.langchain.com.
|
||||
labels: [pending, bug]
|
||||
description: Report a bug in LangGraph. To report a security issue, please instead use the security option (below). For questions, please use the LangChain forum (below).
|
||||
labels: ["bug"]
|
||||
type: bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to file a bug report.
|
||||
Thank you for taking the time to file a bug report.
|
||||
|
||||
Use this to report BUGS in LangGraph. For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
|
||||
For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
|
||||
|
||||
Relevant links to check before filing a bug report to see if your issue has already been reported, fixed or
|
||||
if there's another way to solve your problem:
|
||||
Check these before submitting to see if your issue has already been reported, fixed or if there's another way to solve your problem:
|
||||
|
||||
* [LangChain Forum](https://forum.langchain.com/),
|
||||
* [LangGraph Github Issues](https://github.com/langchain-ai/langgraph/issues),
|
||||
* [LangChain documentation with the integrated search](https://docs.langchain.com/),
|
||||
* [Documentation](https://docs.langchain.com/oss/python/langgraph/overview),
|
||||
* [API Reference Documentation](https://reference.langchain.com/python/),
|
||||
* [LangChain ChatBot](https://chat.langchain.com/)
|
||||
* [GitHub search](https://github.com/langchain-ai/langgraph),
|
||||
* [LangChain Forum](https://forum.langchain.com/),
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Checked other resources
|
||||
description: Before submitting this issue, please confirm that you have completed all the steps below by checking each option. These steps help ensure your issue is well-defined, relevant, and actionable.
|
||||
description: Please confirm and check all the following options.
|
||||
options:
|
||||
- label: This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
|
||||
- label: This is a bug, not a usage question.
|
||||
required: true
|
||||
- label: I added a clear and detailed title that summarizes the issue.
|
||||
- label: I added a clear and descriptive title that summarizes this issue.
|
||||
required: true
|
||||
- label: I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
|
||||
- label: I used the GitHub search to find a similar question and didn't find it.
|
||||
required: true
|
||||
- label: I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.
|
||||
- label: I am sure that this is a bug in LangGraph rather than my code.
|
||||
required: true
|
||||
- label: The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).
|
||||
required: true
|
||||
- label: This is not related to the langchain-community package.
|
||||
required: true
|
||||
- label: I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Example Code
|
||||
label: Reproduction Steps / Example Code (Python)
|
||||
description: |
|
||||
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. Replace this code with your own!
|
||||
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case.
|
||||
|
||||
If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help.
|
||||
|
||||
**Important!**
|
||||
|
||||
* Avoid screenshots, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code.
|
||||
* Reduce your code to the minimum required to reproduce the issue if possible.
|
||||
|
||||
(This will be automatically formatted into code, so no need for backticks.)
|
||||
render: python
|
||||
placeholder: |
|
||||
from langgraph.graph import StateGraph
|
||||
|
||||
@@ -46,17 +63,13 @@ body:
|
||||
|
||||
chain = StateGraph(list)
|
||||
chain.invoke('Hello!')
|
||||
render: python
|
||||
- type: textarea
|
||||
id: error
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: Error Message and Stack Trace (if applicable)
|
||||
description: |
|
||||
If you are reporting an error, please include the full error message and stack trace.
|
||||
placeholder: |
|
||||
Exception + full stack trace
|
||||
If you are reporting an error, please copy and paste the full error message and
|
||||
stack trace.
|
||||
(This will be automatically formatted into code, so no need for backticks.)
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: description
|
||||
@@ -77,7 +90,18 @@ body:
|
||||
attributes:
|
||||
label: System Info
|
||||
description: |
|
||||
Run on your machine: `python -m langchain_core.sys_info`
|
||||
Please share your system info with us.
|
||||
|
||||
Run the following command in your terminal and paste the output here:
|
||||
|
||||
`python -m langchain_core.sys_info`
|
||||
|
||||
or if you have an existing python interpreter running:
|
||||
|
||||
```python
|
||||
from langchain_core import sys_info
|
||||
sys_info.print_sys_info()
|
||||
```
|
||||
placeholder: |
|
||||
python -m langchain_core.sys_info
|
||||
validations:
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
blank_issues_enabled: false
|
||||
version: 2.1
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/langchain-ai/docs/issues/new?template=langgraph.yml
|
||||
about: Report an issue related to the LangGraph documentation
|
||||
- name: LangChain Forum
|
||||
- name: 💬 LangChain Forum
|
||||
url: https://forum.langchain.com/
|
||||
about: General community discussions and support
|
||||
- name: 📚 LangGraph Documentation
|
||||
url: https://docs.langchain.com/oss/python/langgraph/overview
|
||||
about: View the official LangGraph documentation
|
||||
- name: 📚 API Reference Documentation
|
||||
url: https://reference.langchain.com/python/
|
||||
about: View the official LangGraph API reference documentation
|
||||
- name: 📚 Documentation issue
|
||||
url: https://github.com/langchain-ai/docs/issues/new?template=02-langgraph.yml
|
||||
about: Report an issue related to the LangGraph documentation
|
||||
|
||||
@@ -21,7 +21,7 @@ Thank you for contributing to LangGraph! Follow these steps to mark your pull re
|
||||
1. A test for the integration, preferably unit tests that do not rely on network access,
|
||||
2. An example notebook showing its use. It lives in `docs/docs/integrations` directory.
|
||||
|
||||
- [ ] **Lint and test**: Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. We will not consider a PR unless these three are passing in CI. See [contribution guidelines](https://github.com/langchain-ai/langgraph/blob/main/CONTRIBUTING.md) for more.
|
||||
- [ ] **Lint and test**: Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified. We will not consider a PR unless these three are passing in CI. See [contribution guidelines](https://docs.langchain.com/oss/python/contributing/overview) for more.
|
||||
|
||||
Additional guidelines:
|
||||
|
||||
|
||||
+102
-9
@@ -4,15 +4,108 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directories:
|
||||
- "libs/checkpoint"
|
||||
- "libs/checkpoint-postgres"
|
||||
- "libs/checkpoint-sqlite"
|
||||
- "libs/cli"
|
||||
- "libs/langgraph"
|
||||
- "libs/prebuilt"
|
||||
- "libs/sdk-py"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/checkpoint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/checkpoint-conformance"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/checkpoint-postgres"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/checkpoint-sqlite"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/cli"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/langgraph"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/prebuilt"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/libs/sdk-py"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/libs/cli/js-examples"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/libs/cli/js-monorepo-example"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
groups:
|
||||
all-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="100" y="6.10352e-05" width="100" height="100" rx="20" transform="rotate(90 100 6.10352e-05)" fill="#161F34"/>
|
||||
<path d="M32.1494 67.8579H45.2266C45.2246 75.0778 39.3716 80.93 32.1514 80.9302C24.9301 80.9301 19.0756 75.0762 19.0752 67.855C19.0752 60.6341 24.9288 54.78 32.1494 54.7788V67.8579ZM67.8691 54.7788C75.0906 54.779 80.9443 60.6335 80.9443 67.855C80.944 75.0762 75.0904 80.93 67.8691 80.9302C60.6488 80.9301 54.7949 75.0778 54.793 67.8579H67.8594V54.7788C67.8626 54.7788 67.8659 54.7788 67.8691 54.7788ZM67.8691 19.0757C75.0906 19.0759 80.9443 24.9304 80.9443 32.1519C80.944 39.3731 75.0904 45.2269 67.8691 45.2271C67.8659 45.2271 67.8626 45.2261 67.8594 45.2261V32.1479H54.793C54.795 24.9281 60.6489 19.0758 67.8691 19.0757ZM32.1514 19.0757C39.3716 19.0759 45.2246 24.9281 45.2266 32.1479H32.1494V45.2261C24.929 45.2249 19.0755 39.3725 19.0752 32.1519C19.0752 24.9303 24.9299 19.0758 32.1514 19.0757Z" fill="#7FC8FF"/>
|
||||
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM303.555 82.608C295.755 82.608 290.295 78.948 289.515 72.228H294.555C295.395 76.068 298.815 78.228 303.915 78.228C310.335 78.228 313.815 75.228 313.815 68.568V64.968C311.775 68.448 308.475 70.608 303.315 70.608C294.855 70.608 288.615 64.908 288.615 54.948C288.615 45.588 294.855 39.288 303.315 39.288C308.475 39.288 311.895 41.508 313.815 44.928L314.475 39.648H318.735V68.748C318.735 77.568 314.055 82.608 303.555 82.608ZM303.795 66.228C309.795 66.228 313.875 61.608 313.875 55.068C313.875 48.348 309.795 43.668 303.795 43.668C297.735 43.668 293.655 48.288 293.655 54.948C293.655 61.608 297.735 66.228 303.795 66.228ZM327.862 70.248V65.748H335.422V44.148H327.862V39.648H340.222V44.928C341.602 42.588 344.482 39.648 350.482 39.648H355.582V44.448H349.942C342.562 44.448 340.342 49.968 340.342 54.828V65.748H353.902V70.248H327.862ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM401.076 82.248V39.648H405.336L405.996 44.568C408.036 41.748 411.336 39.288 416.496 39.288C424.956 39.288 431.196 44.988 431.196 54.948C431.196 64.308 424.956 70.608 416.496 70.608C411.336 70.608 407.856 68.508 405.996 65.568V82.248H401.076ZM416.016 66.228C422.076 66.228 426.156 61.608 426.156 54.948C426.156 48.288 422.076 43.668 416.016 43.668C410.016 43.668 405.936 48.288 405.936 54.828C405.936 61.548 410.016 66.228 416.016 66.228ZM439.663 70.248V28.248H444.583V43.788C446.863 40.968 450.403 39.288 454.363 39.288C462.043 39.288 466.423 44.388 466.423 53.208V70.248H461.503V53.508C461.503 47.268 458.623 43.788 453.523 43.788C448.063 43.788 444.583 48.108 444.583 54.948V70.248H439.663Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="100" width="100" height="100" rx="20" transform="rotate(90 100 0)" fill="#161F34"/>
|
||||
<path d="M32.1494 67.8578H45.2266C45.2246 75.0776 39.3716 80.9299 32.1514 80.9301C24.9301 80.9299 19.0756 75.0761 19.0752 67.8549C19.0752 60.634 24.9288 54.7799 32.1494 54.7787V67.8578ZM67.8691 54.7787C75.0906 54.7789 80.9443 60.6334 80.9443 67.8549C80.944 75.076 75.0904 80.9299 67.8691 80.9301C60.6488 80.9299 54.7949 75.0777 54.793 67.8578H67.8594V54.7787C67.8626 54.7787 67.8659 54.7787 67.8691 54.7787ZM67.8691 19.0756C75.0906 19.0758 80.9443 24.9303 80.9443 32.1517C80.944 39.373 75.0904 45.2267 67.8691 45.2269C67.8659 45.2269 67.8626 45.226 67.8594 45.226V32.1478H54.793C54.795 24.928 60.6489 19.0757 67.8691 19.0756ZM32.1514 19.0756C39.3716 19.0757 45.2246 24.928 45.2266 32.1478H32.1494V45.226C24.929 45.2248 19.0755 39.3724 19.0752 32.1517C19.0752 24.9302 24.9299 19.0757 32.1514 19.0756Z" fill="#7FC8FF"/>
|
||||
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM303.555 82.608C295.755 82.608 290.295 78.948 289.515 72.228H294.555C295.395 76.068 298.815 78.228 303.915 78.228C310.335 78.228 313.815 75.228 313.815 68.568V64.968C311.775 68.448 308.475 70.608 303.315 70.608C294.855 70.608 288.615 64.908 288.615 54.948C288.615 45.588 294.855 39.288 303.315 39.288C308.475 39.288 311.895 41.508 313.815 44.928L314.475 39.648H318.735V68.748C318.735 77.568 314.055 82.608 303.555 82.608ZM303.795 66.228C309.795 66.228 313.875 61.608 313.875 55.068C313.875 48.348 309.795 43.668 303.795 43.668C297.735 43.668 293.655 48.288 293.655 54.948C293.655 61.608 297.735 66.228 303.795 66.228ZM327.862 70.248V65.748H335.422V44.148H327.862V39.648H340.222V44.928C341.602 42.588 344.482 39.648 350.482 39.648H355.582V44.448H349.942C342.562 44.448 340.342 49.968 340.342 54.828V65.748H353.902V70.248H327.862ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM401.076 82.248V39.648H405.336L405.996 44.568C408.036 41.748 411.336 39.288 416.496 39.288C424.956 39.288 431.196 44.988 431.196 54.948C431.196 64.308 424.956 70.608 416.496 70.608C411.336 70.608 407.856 68.508 405.996 65.568V82.248H401.076ZM416.016 66.228C422.076 66.228 426.156 61.608 426.156 54.948C426.156 48.288 422.076 43.668 416.016 43.668C410.016 43.668 405.936 48.288 405.936 54.828C405.936 61.548 410.016 66.228 416.016 66.228ZM439.663 70.248V28.248H444.583V43.788C446.863 40.968 450.403 39.288 454.363 39.288C462.043 39.288 466.423 44.388 466.423 53.208V70.248H461.503V53.508C461.503 47.268 458.623 43.788 453.523 43.788C448.063 43.788 444.583 48.108 444.583 54.948V70.248H439.663Z" fill="#161F34"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -63,7 +63,7 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
|
||||
try:
|
||||
sys.stderr.write("\n== docker compose ps ==\n")
|
||||
runner.run(
|
||||
subp_exec(*compose_cmd, *args, "ps", input=stdin, verbose=False)
|
||||
subp_exec(*compose_cmd, *args, "ps", input=stdin, verbose=True)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -76,7 +76,7 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
|
||||
"logs",
|
||||
"langgraph-api",
|
||||
input=stdin,
|
||||
verbose=False,
|
||||
verbose=True,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -2,6 +2,9 @@ name: CLI integration test
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
LANGSMITH_API_KEY:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -28,6 +31,8 @@ jobs:
|
||||
workdir: libs/cli/examples/graphs_reqs_b
|
||||
tag: langgraph-test-d
|
||||
name: "CLI integration test"
|
||||
env:
|
||||
HAS_LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY != '' }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
@@ -35,11 +40,12 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
with:
|
||||
filter: "libs/cli/**"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.changed-files.outputs.all
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -47,57 +53,72 @@ jobs:
|
||||
cache-suffix: "cli-integration-test"
|
||||
ignore-nothing-to-cache: true
|
||||
- name: Install cli globally
|
||||
if: steps.changed-files.outputs.all
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
run: pip install -e .
|
||||
- name: Build and test service ${{ matrix.example.name }}
|
||||
if: steps.changed-files.outputs.all
|
||||
- name: Build service ${{ matrix.example.name }}
|
||||
if: (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch')
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
run: |
|
||||
langgraph build -t ${{ matrix.example.tag }}
|
||||
- name: Test service ${{ matrix.example.name }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: ${{ matrix.example.workdir }}
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
run: |
|
||||
# Build the image for this example
|
||||
langgraph build -t ${{ matrix.example.tag }}
|
||||
# Prepare environment file from local or parent example directory
|
||||
if [ -f .env.example ]; then cp .env.example .env; elif [ -f ../.env.example ]; then cp ../.env.example .env && cp ../.env.example ../.env; fi
|
||||
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi; fi
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi
|
||||
# Run the integration test using the built tag
|
||||
# Compute repo root to reference the shared script robustly
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
||||
|
||||
- name: Build JS service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/js-examples
|
||||
run: |
|
||||
langgraph build -t langgraph-test-e
|
||||
|
||||
- name: Build JS monorepo service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/js-monorepo-example
|
||||
run: |
|
||||
langgraph build -t langgraph-test-f -c apps/agent/langgraph.json --build-command "yarn run turbo build" --install-command "yarn install"
|
||||
|
||||
- name: Build Python monorepo service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
run: |
|
||||
langgraph build -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
- name: Test Python monorepo service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: libs/cli/python-monorepo-example
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
run: |
|
||||
cp apps/agent/.env.example apps/agent/.env
|
||||
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env; fi
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
|
||||
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
||||
|
||||
- name: Build and test prerelease reqs service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
- name: Build prerelease reqs service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
run: |
|
||||
langgraph build -t langgraph-test-h
|
||||
- name: Test prerelease reqs service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' && env.HAS_LANGSMITH_API_KEY == 'true' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs
|
||||
env:
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
run: |
|
||||
cp ../.env.example .env
|
||||
if [ -n "${{ secrets.LANGSMITH_API_KEY }}" ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env; fi
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
||||
echo "Finished starting up langgraph-test-h"
|
||||
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
|
||||
if [ "$LANGGRAPH_VERSION" != "1.0.2" ]; then
|
||||
echo "LANGGRAPH_VERSION != 1.0.2; $LANGGRAPH_VERSION"
|
||||
if [ "$LANGGRAPH_VERSION" != "1.0.8" ]; then
|
||||
echo "LANGGRAPH_VERSION != 1.0.8; $LANGGRAPH_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
|
||||
@@ -112,7 +133,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build and test prerelease reqs fail service
|
||||
if: ${{ steps.changed-files.outputs.all && matrix.example.name == 'A' }}
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
working-directory: libs/cli/examples/graph_prerelease_reqs_fail
|
||||
run: |
|
||||
langgraph build -t langgraph-test-i || [ $? -eq 1 ]
|
||||
|
||||
@@ -34,11 +34,12 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
with:
|
||||
filter: "${{ inputs.working-directory }}/**"
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -46,12 +47,12 @@ jobs:
|
||||
cache-suffix: lint-${{ inputs.working-directory }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --frozen --group lint
|
||||
|
||||
- name: Get .mypy_cache to speed up mypy
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
|
||||
@@ -61,7 +62,7 @@ jobs:
|
||||
key: mypy-lint-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory)) }}
|
||||
|
||||
- name: Analysing package code with our lint
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
if make lint_package > /dev/null 2>&1; then
|
||||
@@ -72,12 +73,12 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install test dependencies
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --group lint
|
||||
|
||||
- name: Get .mypy_cache_test to speed up mypy
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
SEGMENT_DOWNLOAD_TIMEOUT_MIN: "2"
|
||||
@@ -87,7 +88,7 @@ jobs:
|
||||
key: mypy-test-${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ inputs.working-directory }}-${{ hashFiles(format('{0}/uv.lock', inputs.working-directory)) }}
|
||||
|
||||
- name: Analysing tests with our lint
|
||||
if: steps.changed-files.outputs.all
|
||||
if: steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch'
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
if make lint_tests > /dev/null 2>&1; then
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-suffix: test-${{ inputs.working-directory }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-suffix: "test-langgraph"
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -45,6 +45,13 @@ jobs:
|
||||
shell: bash
|
||||
run: make test_parallel
|
||||
|
||||
- name: Run strict msgpack pregel tests
|
||||
if: ${{ matrix.python-version == '3.13' }}
|
||||
shell: bash
|
||||
env:
|
||||
LANGGRAPH_STRICT_MSGPACK: "true"
|
||||
run: make test TEST="tests/test_pregel.py tests/test_pregel_async.py"
|
||||
|
||||
- name: Ensure the tests did not create any additional files
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: test-dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -24,11 +26,12 @@ jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
python: ${{ steps.filter.outputs.python }}
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
python: ${{ steps.filter.outputs.python || 'true' }}
|
||||
deps: ${{ steps.filter.outputs.deps || 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -39,6 +42,7 @@ jobs:
|
||||
- 'libs/checkpoint/**'
|
||||
- 'libs/checkpoint-sqlite/**'
|
||||
- 'libs/checkpoint-postgres/**'
|
||||
- 'libs/checkpoint-conformance/**'
|
||||
- 'libs/prebuilt/**'
|
||||
deps:
|
||||
- '**/pyproject.toml'
|
||||
@@ -57,7 +61,7 @@ jobs:
|
||||
"libs/checkpoint",
|
||||
"libs/checkpoint-sqlite",
|
||||
"libs/checkpoint-postgres",
|
||||
|
||||
"libs/checkpoint-conformance",
|
||||
"libs/prebuilt",
|
||||
]
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
|
||||
@@ -77,6 +81,7 @@ jobs:
|
||||
"libs/checkpoint",
|
||||
"libs/checkpoint-sqlite",
|
||||
"libs/checkpoint-postgres",
|
||||
"libs/checkpoint-conformance",
|
||||
"libs/prebuilt",
|
||||
"libs/sdk-py",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Deploy Redirects to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-redirects.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Generate redirect files
|
||||
run: python docs/generate_redirects.py
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: 'docs/_site'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-suffix: "release"
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-suffix: "release"
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ inputs.working-directory }}/dist/
|
||||
|
||||
@@ -100,3 +100,10 @@ dmypy.json
|
||||
.turbo
|
||||
.editorconfig
|
||||
.scratch
|
||||
|
||||
# macOS debug symbol bundles generated during local Rust builds
|
||||
saf-python-sdk/python/saf_python_sdk/*.dSYM/
|
||||
|
||||
# Local PyO3 extension artifacts for saf-python-sdk
|
||||
saf-python-sdk/python/saf_python_sdk/langgraph_rust_core*.so
|
||||
saf-python-sdk/python/saf_python_sdk/langgraph_rust_core*.pyd
|
||||
|
||||
@@ -53,3 +53,5 @@ sdk-js (standalone)
|
||||
```
|
||||
|
||||
Changes to a library may impact all of its dependents shown above.
|
||||
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
|
||||
@@ -53,3 +53,5 @@ sdk-js (standalone)
|
||||
```
|
||||
|
||||
Changes to a library may impact all of its dependents shown above.
|
||||
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<picture class="github-only">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://langchain-ai.github.io/langgraph/static/wordmark_light.svg">
|
||||
<img alt="LangGraph Logo" src="https://langchain-ai.github.io/langgraph/static/wordmark_dark.svg" width="80%">
|
||||
<source media="(prefers-color-scheme: light)" srcset=".github/images/logo-light.svg">
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/images/logo-dark.svg">
|
||||
<img alt="LangGraph Logo" src=".github/images/logo-dark.svg" width="50%">
|
||||
</picture>
|
||||
|
||||
<div>
|
||||
@@ -56,6 +56,9 @@ Get started with the [LangGraph Quickstart](https://docs.langchain.com/oss/pytho
|
||||
|
||||
To quickly build agents with LangChain's `create_agent` (built on LangGraph), see the [LangChain Agents documentation](https://docs.langchain.com/oss/python/langchain/agents).
|
||||
|
||||
> [!TIP]
|
||||
> For developing, debugging, and deploying AI agents and LLM applications, see [LangSmith](https://docs.langchain.com/langsmith/home).
|
||||
|
||||
## Core benefits
|
||||
|
||||
LangGraph provides low-level supporting infrastructure for *any* long-running, stateful workflow or agent. LangGraph does not abstract prompts or architecture, and provides the following central benefits:
|
||||
@@ -79,7 +82,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [Guides](https://docs.langchain.com/oss/python/langgraph/guides): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
|
||||
- [Guides](https://docs.langchain.com/oss/python/langgraph/overview): Quick, actionable code snippets for topics such as streaming, adding memory & persistence, and design patterns (e.g. branching, subgraphs, etc.).
|
||||
- [Reference](https://reference.langchain.com/python/langgraph/): Detailed reference on core classes, methods, how to use the graph and checkpointing APIs, and higher-level prebuilt components.
|
||||
- [Examples](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Guided examples on getting started with LangGraph.
|
||||
- [LangChain Forum](https://forum.langchain.com/): Connect with the community and share all of your technical questions, ideas, and feedback.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
PYTHON_VERSION ?= 3.13
|
||||
|
||||
.PHONY: run-basic
|
||||
run-basic:
|
||||
uv run --python $(PYTHON_VERSION) python examples/basic_run.py
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# advanced-graph-examples
|
||||
|
||||
Examples for the published `saf-python-sdk` package.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd advanced-graph-examples
|
||||
uv sync
|
||||
uv run python examples/basic_run.py
|
||||
```
|
||||
|
||||
> Note: current published wheel is for Python 3.13 on macOS arm64.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TypedDict
|
||||
|
||||
from saf_python_sdk import Command, Send
|
||||
from saf_python_sdk.advanced_graph import AdvancedStateGraph
|
||||
|
||||
|
||||
class MyState(TypedDict):
|
||||
count: int
|
||||
logs: list[str]
|
||||
|
||||
|
||||
async def start_node(state: MyState) -> Command:
|
||||
state["logs"].append("start")
|
||||
return Command(update=state, goto=Send("finish_node", "hello"))
|
||||
|
||||
|
||||
async def finish_node(input: str, state: MyState) -> Command:
|
||||
state["logs"].append(f"finish:{input}")
|
||||
state["count"] += 1
|
||||
return Command(update=state)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
graph = AdvancedStateGraph(MyState)
|
||||
graph.add_entry_node(start_node)
|
||||
graph.add_finish_node(finish_node)
|
||||
result = await graph.compile().ainvoke({"count": 0, "logs": []})
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "advanced-graph-examples"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.13,<3.14"
|
||||
dependencies = [
|
||||
"saf-python-sdk>=0.1.1",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = []
|
||||
|
||||
|
||||
Generated
+25
@@ -0,0 +1,25 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
[[package]]
|
||||
name = "advanced-graph-examples"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "saf-python-sdk" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "saf-python-sdk", specifier = ">=0.1.1" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = []
|
||||
|
||||
[[package]]
|
||||
name = "saf-python-sdk"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/43/2a86cdc8a1fbb185b9ce4642f0e3676acc168d48ed2e68d4ead6ad57e95e/saf_python_sdk-0.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9035ab6ede9884b5588925c47d5b8be2c1c107becf5d6f88e0ebb39f4e8fac3b", size = 517053, upload-time = "2026-03-16T23:49:25.661Z" },
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
_site/
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate HTML redirect files from redirects.json.
|
||||
|
||||
Usage:
|
||||
python generate_redirects.py
|
||||
|
||||
This script reads redirects.json and generates individual HTML files
|
||||
for each redirect path. Each HTML file uses meta refresh (0 delay)
|
||||
which is SEO-friendly and treated similarly to 301 redirects by Google.
|
||||
|
||||
To add new redirects, simply edit redirects.json and re-run this script.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Default fallback URL for any path not in the redirect map
|
||||
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
|
||||
HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting...</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
Redirecting...
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
ROOT_HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
CATCHALL_404_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{default_url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
// Catchall redirect for any unmapped paths
|
||||
window.location.replace("{default_url}");
|
||||
</script>
|
||||
<meta http-equiv="refresh" content="0; url={default_url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{default_url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def generate_redirects():
|
||||
script_dir = Path(__file__).parent
|
||||
output_dir = script_dir / "_site"
|
||||
|
||||
# Load redirects
|
||||
with open(script_dir / "redirects.json") as f:
|
||||
redirects = json.load(f)
|
||||
|
||||
# Clean output directory
|
||||
if output_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True)
|
||||
|
||||
# Generate individual HTML files for each redirect
|
||||
for old_path, new_url in redirects.items():
|
||||
# Remove leading slash and create directory structure
|
||||
path = old_path.lstrip("/")
|
||||
|
||||
# Check if path has a file extension (e.g., .txt, .xml)
|
||||
# If so, create the file directly instead of a directory with index.html
|
||||
path_obj = Path(path)
|
||||
has_extension = path_obj.suffix and len(path_obj.suffix) <= 5
|
||||
|
||||
if not path:
|
||||
html_path = output_dir / "index.html"
|
||||
elif has_extension:
|
||||
# For files with extensions, create the file directly
|
||||
html_path = output_dir / path
|
||||
else:
|
||||
# For directory-style URLs, create index.html inside
|
||||
html_path = output_dir / path / "index.html"
|
||||
|
||||
# Create parent directories
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the redirect HTML
|
||||
html_path.write_text(HTML_TEMPLATE.format(url=new_url))
|
||||
print(f"Created: {html_path}")
|
||||
|
||||
# Create root index.html
|
||||
root_index = output_dir / "index.html"
|
||||
if not root_index.exists():
|
||||
root_index.write_text(ROOT_HTML_TEMPLATE.format(url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {root_index}")
|
||||
|
||||
# Create 404.html for catchall
|
||||
catchall_404 = output_dir / "404.html"
|
||||
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {catchall_404}")
|
||||
|
||||
# Copy static files (like llms.txt) that can't be redirected via HTML
|
||||
static_files = ["llms.txt"]
|
||||
for static_file in static_files:
|
||||
src = script_dir / static_file
|
||||
if src.exists():
|
||||
dst = output_dir / static_file
|
||||
dst.write_text(src.read_text())
|
||||
print(f"Copied: {dst}")
|
||||
|
||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_redirects()
|
||||
@@ -0,0 +1,35 @@
|
||||
# LangGraph
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
|
||||
## Overview
|
||||
|
||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
|
||||
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
|
||||
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
|
||||
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
|
||||
|
||||
## How-To Guides
|
||||
|
||||
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
|
||||
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
|
||||
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
|
||||
|
||||
## Tutorials
|
||||
|
||||
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
|
||||
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
|
||||
|
||||
## Reference
|
||||
|
||||
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
|
||||
|
||||
## LangGraph Platform
|
||||
|
||||
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"/how-tos/stream-values": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-updates": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-content": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-multiple": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-tokens-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-from-final-node": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-events-from-within-tools-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/state-reducers": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state",
|
||||
"/how-tos/sequence": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps",
|
||||
"/how-tos/branching": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches",
|
||||
"/how-tos/recursion-limit": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops",
|
||||
"/how-tos/visualization": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph",
|
||||
"/how-tos/input_output_schema": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas",
|
||||
"/how-tos/pass_private_state": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes",
|
||||
"/how-tos/state-model": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state",
|
||||
"/how-tos/map-reduce": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api",
|
||||
"/how-tos/command": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command",
|
||||
"/how-tos/configuration": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration",
|
||||
"/how-tos/node-retries": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies",
|
||||
"/how-tos/return-when-recursion-limit-hits": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit",
|
||||
"/how-tos/async": "https://docs.langchain.com/oss/python/langgraph/graph-api#async",
|
||||
"/how-tos/memory/manage-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/memory/delete-messages": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages",
|
||||
"/how-tos/memory/add-summary-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages",
|
||||
"/how-tos/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/subgraph-transform-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas",
|
||||
"/how-tos/subgraphs-manage-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence",
|
||||
"/how-tos/persistence_postgres": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_mongodb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_redis": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/subgraph-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs",
|
||||
"/how-tos/cross-thread-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/concepts/threads": "https://docs.langchain.com/oss/python/langgraph/persistence#threads",
|
||||
"/how-tos/persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/tool-calling-errors": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-config-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-run-time-values-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/update-state-from-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/agent-handoffs": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-multi-turn-convo": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/cloud/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/cloud/how-tos/index": "https://docs.langchain.com/langsmith/home",
|
||||
"/cloud/concepts/api": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/cloud/concepts/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/faq/studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/human_in_the_loop_edit_state": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/how-tos/human_in_the_loop_user_input": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/concepts/platform_architecture": "https://docs.langchain.com/langsmith/cloud#architecture",
|
||||
"/cloud/how-tos/stream_values": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_updates": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_messages": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_events": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_debug": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_multiple": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/agents/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/create-react-agent": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration",
|
||||
"/how-tos/create-react-agent-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-system-prompt": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/prebuilt": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/concepts/high_level": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/v0-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/introduction": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/deployment": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/how-tos/deploy-self-hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/self_hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/tutorials/deployment": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/how-tos/assistant_versioning": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/concepts/runs": "https://docs.langchain.com/langsmith/assistants#execution",
|
||||
"/how-tos/wait-user-input-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/review-tool-calls-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/create-react-agent-hitl": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/agents/human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/dynamic_breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/human_in_the_loop_breakpoint": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/how-tos/human_in_the_loop/edit-graph-state": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/examples/index": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/guides/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/tutorials/index": "https://docs.langchain.com/oss/python/learn",
|
||||
"/llms-txt-overview": "https://docs.langchain.com/llms.txt",
|
||||
"/tutorials/rag/langgraph_adaptive_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/multi-agent-collaboration": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/create-react-agent-manage-message-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/many-tools": "https://docs.langchain.com/oss/python/langchain/tools",
|
||||
"/tutorials/customer-support/customer-support": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/how-tos/react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/tutorials/code_assistant/langgraph_code_assistant": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/hierarchical_agent_teams": "https://docs.langchain.com/oss/python/langchain/supervisor",
|
||||
"/tutorials/auth/getting_started": "https://docs.langchain.com/langsmith/auth",
|
||||
"/tutorials/auth/resource_auth": "https://docs.langchain.com/langsmith/resource-auth",
|
||||
"/tutorials/auth/add_auth_server": "https://docs.langchain.com/langsmith/add-auth-server",
|
||||
"/how-tos/use-remote-graph": "https://docs.langchain.com/langsmith/use-remote-graph",
|
||||
"/how-tos/autogen-integration": "https://docs.langchain.com/langsmith/autogen-integration",
|
||||
"/how-tos/human_in_the_loop/wait-user-input": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/use_stream_react": "https://docs.langchain.com/langsmith/use-stream-react",
|
||||
"/cloud/how-tos/generative_ui_react": "https://docs.langchain.com/langsmith/generative-ui-react",
|
||||
"/concepts/langgraph_platform": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/langgraph_components": "https://docs.langchain.com/langsmith/components",
|
||||
"/concepts/langgraph_server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/concepts/langgraph_data_plane": "https://docs.langchain.com/langsmith/data-plane",
|
||||
"/concepts/langgraph_control_plane": "https://docs.langchain.com/langsmith/control-plane",
|
||||
"/concepts/langgraph_cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/concepts/langgraph_studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/studio/quick_start": "https://docs.langchain.com/langsmith/quick-start-studio",
|
||||
"/cloud/how-tos/invoke_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/manage_assistants": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/threads_studio": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/iterate_graph_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/run_evals": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/clone_traces_studio": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/datasets_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/concepts/sdk": "https://docs.langchain.com/langsmith/sdk",
|
||||
"/concepts/plans": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/application_structure": "https://docs.langchain.com/langsmith/application-structure",
|
||||
"/concepts/scalability_and_resilience": "https://docs.langchain.com/langsmith/scalability-and-resilience",
|
||||
"/concepts/auth": "https://docs.langchain.com/langsmith/auth",
|
||||
"/how-tos/auth/custom_auth": "https://docs.langchain.com/langsmith/custom-auth",
|
||||
"/how-tos/auth/openapi_security": "https://docs.langchain.com/langsmith/openapi-security",
|
||||
"/concepts/assistants": "https://docs.langchain.com/langsmith/assistants",
|
||||
"/cloud/how-tos/configuration_cloud": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/how-tos/use_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/background_run": "https://docs.langchain.com/langsmith/background-run",
|
||||
"/cloud/how-tos/same-thread": "https://docs.langchain.com/langsmith/same-thread",
|
||||
"/cloud/how-tos/stateless_runs": "https://docs.langchain.com/langsmith/stateless-runs",
|
||||
"/cloud/how-tos/configurable_headers": "https://docs.langchain.com/langsmith/configurable-headers",
|
||||
"/concepts/double_texting": "https://docs.langchain.com/langsmith/double-texting",
|
||||
"/cloud/how-tos/interrupt_concurrent": "https://docs.langchain.com/langsmith/interrupt-concurrent",
|
||||
"/cloud/how-tos/rollback_concurrent": "https://docs.langchain.com/langsmith/rollback-concurrent",
|
||||
"/cloud/how-tos/reject_concurrent": "https://docs.langchain.com/langsmith/reject-concurrent",
|
||||
"/cloud/how-tos/enqueue_concurrent": "https://docs.langchain.com/langsmith/enqueue-concurrent",
|
||||
"/cloud/concepts/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/how-tos/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/concepts/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/cloud/how-tos/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/how-tos/http/custom_lifespan": "https://docs.langchain.com/langsmith/custom-lifespan",
|
||||
"/how-tos/http/custom_middleware": "https://docs.langchain.com/langsmith/custom-middleware",
|
||||
"/how-tos/http/custom_routes": "https://docs.langchain.com/langsmith/custom-routes",
|
||||
"/cloud/concepts/data_storage_and_privacy": "https://docs.langchain.com/langsmith/data-storage-and-privacy",
|
||||
"/cloud/deployment/semantic_search": "https://docs.langchain.com/langsmith/semantic-search",
|
||||
"/how-tos/ttl/configure_ttl": "https://docs.langchain.com/langsmith/configure-ttl",
|
||||
"/concepts/deployment_options": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/quick_start": "https://docs.langchain.com/langsmith/deployment-quickstart",
|
||||
"/cloud/deployment/setup": "https://docs.langchain.com/langsmith/setup-app-requirements-txt",
|
||||
"/cloud/deployment/setup_pyproject": "https://docs.langchain.com/langsmith/setup-pyproject",
|
||||
"/cloud/deployment/setup_javascript": "https://docs.langchain.com/langsmith/setup-javascript",
|
||||
"/cloud/deployment/custom_docker": "https://docs.langchain.com/langsmith/custom-docker",
|
||||
"/cloud/deployment/graph_rebuild": "https://docs.langchain.com/langsmith/graph-rebuild",
|
||||
"/concepts/langgraph_cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/concepts/langgraph_self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/cloud/deployment/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/deployment/self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/concepts/server-mcp": "https://docs.langchain.com/langsmith/server-mcp",
|
||||
"/cloud/how-tos/human_in_the_loop_time_travel": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
|
||||
"/cloud/how-tos/add-human-in-the-loop": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/deployment/egress": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/cloud/how-tos/streaming": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/reference/api/api_ref": "https://docs.langchain.com/langsmith/server-api-ref",
|
||||
"/cloud/reference/langgraph_server_changelog": "https://docs.langchain.com/langsmith/agent-server-changelog",
|
||||
"/cloud/reference/api/api_ref_control_plane": "https://docs.langchain.com/langsmith/api-ref-control-plane",
|
||||
"/cloud/reference/cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/cloud/reference/env_var": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/troubleshooting/studio": "https://docs.langchain.com/langsmith/troubleshooting-studio",
|
||||
"/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/agents": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/concepts/why-langgraph": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/get-started/1-build-basic-chatbot": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/2-add-tools": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/3-add-memory": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/4-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/5-customize-state": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/6-time-travel": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/langsmith/local-server": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/tutorials/workflows": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/plan-and-execute/plan-and-execute": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list",
|
||||
"/tutorials/langgraph-platform/local-server/local-server": "https://docs.langchain.com/langsmith/local-server",
|
||||
"/concepts/agentic_concepts": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/overview": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/agents/run_agents": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/concepts/low_level": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/graph-api": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/react-agent-from-scratch": "https://docs.langchain.com/oss/python/langchain/quickstart",
|
||||
"/concepts/functional_api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/use-functional-api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/concepts/pregel": "https://docs.langchain.com/oss/python/langgraph/pregel",
|
||||
"/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/concepts/persistence": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/concepts/durable_execution": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
|
||||
"/concepts/memory": "https://docs.langchain.com/oss/python/langgraph/memory",
|
||||
"/how-tos/memory/add-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/context": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/models": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/tool-calling": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/concepts/human_in_the_loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/add-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/time-travel": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/how-tos/human_in_the_loop/time-travel": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/concepts/subgraphs": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/how-tos/subgraph": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/concepts/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/agents/multi-agent": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/concepts/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/how-tos/enable-tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/agents/evals": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/template_applications": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_agentic_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/agent_supervisor": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/sql/sql-agent": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
|
||||
"/agents/ui": "https://docs.langchain.com/oss/python/langgraph/ui",
|
||||
"/how-tos/run-id-langsmith": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/troubleshooting/errors/index": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/troubleshooting/errors/INVALID_CHAT_HISTORY": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
|
||||
"/troubleshooting/errors/INVALID_LICENSE": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/adopters": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/concepts/faq": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/index": "https://reference.langchain.com/python/langgraph/",
|
||||
"/reference/graphs": "https://reference.langchain.com/python/langgraph/graphs/",
|
||||
"/reference/func": "https://reference.langchain.com/python/langgraph/func/",
|
||||
"/reference/pregel": "https://reference.langchain.com/python/langgraph/pregel/",
|
||||
"/reference/checkpoints": "https://reference.langchain.com/python/langgraph/checkpoints/",
|
||||
"/reference/store": "https://reference.langchain.com/python/langgraph/store/",
|
||||
"/reference/cache": "https://reference.langchain.com/python/langgraph/cache/",
|
||||
"/reference/types": "https://reference.langchain.com/python/langgraph/types/",
|
||||
"/reference/runtime": "https://reference.langchain.com/python/langgraph/runtime/",
|
||||
"/reference/config": "https://reference.langchain.com/python/langgraph/config/",
|
||||
"/reference/errors": "https://reference.langchain.com/python/langgraph/errors/",
|
||||
"/reference/constants": "https://reference.langchain.com/python/langgraph/constants/",
|
||||
"/reference/channels": "https://reference.langchain.com/python/langgraph/channels/",
|
||||
"/reference/agents": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/reference/supervisor": "https://reference.langchain.com/python/langgraph/supervisor/",
|
||||
"/reference/swarm": "https://reference.langchain.com/python/langgraph/swarm/",
|
||||
"/reference/mcp": "https://reference.langchain.com/python/langgraph/mcp/",
|
||||
"/cloud/reference/sdk/python_sdk_ref": "https://reference.langchain.com/python/langsmith/deployment/sdk/",
|
||||
"/reference/remote_graph": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/",
|
||||
"/additional-resources/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/cloud/reference/sdk/js_ts_sdk_ref": "https://reference.langchain.com/javascript/modules/langsmith.html",
|
||||
"/snippets/chat_model_tabs": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/troubleshooting/errors/GRAPH_RECURSION_LIMIT": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
|
||||
"/troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
|
||||
"/troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
|
||||
"/troubleshooting/errors/MULTIPLE_SUBGRAPHS": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
|
||||
"/tutorials/rag/langgraph_self_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/additional-resources": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/examples": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/guides": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/autogen-integration-functional": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/cross-thread-persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/how-tos/disable-streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/memory/semantic-search": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/multi-agent-multi-turn-convo-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/react-agent-from-scratch-functional": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/reference": "https://reference.langchain.com/python/langgraph/",
|
||||
"/troubleshooting/errors": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbots/information-gather-prompting": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/extraction/retries": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/langgraph-platform/local-server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/tutorials/lats/lats": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/llm-compiler/LLMCompiler": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_adaptive_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_self_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/reflection/reflection": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/reflexion/reflexion": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rewoo/rewoo": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/self-discover/self-discover": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tnt-llm/tnt-llm": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tot/tot": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/usaco/usaco": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/web-navigation/web_voyager": "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# LangGraph examples
|
||||
|
||||
This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview). Please refer to the LangChain docs for the most up-to-date examples and usage guidelines for LangGraph.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "10251c1c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c5fc63df",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a4351a24",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "4cc9af1e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import functools
|
||||
from typing import Annotated, Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from langchain_community.adapters.openai import convert_message_to_dict
|
||||
from langchain_core.messages import AIMessage, AnyMessage, BaseMessage, HumanMessage
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from langchain_core.runnables import Runnable, RunnableLambda
|
||||
from langchain_core.runnables import chain as as_runnable
|
||||
from langchain_openai import ChatOpenAI
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import END, StateGraph, START
|
||||
|
||||
|
||||
def langchain_to_openai_messages(messages: List[BaseMessage]):
|
||||
"""
|
||||
Convert a list of langchain base messages to a list of openai messages.
|
||||
|
||||
Parameters:
|
||||
messages (List[BaseMessage]): A list of langchain base messages.
|
||||
|
||||
Returns:
|
||||
List[dict]: A list of openai messages.
|
||||
"""
|
||||
|
||||
return [
|
||||
convert_message_to_dict(m) if isinstance(m, BaseMessage) else m
|
||||
for m in messages
|
||||
]
|
||||
|
||||
|
||||
def create_simulated_user(
|
||||
system_prompt: str, llm: Runnable | None = None
|
||||
) -> Runnable[Dict, AIMessage]:
|
||||
"""
|
||||
Creates a simulated user for chatbot simulation.
|
||||
|
||||
Args:
|
||||
system_prompt (str): The system prompt to be used by the simulated user.
|
||||
llm (Runnable | None, optional): The language model to be used for the simulation.
|
||||
Defaults to gpt-3.5-turbo.
|
||||
|
||||
Returns:
|
||||
Runnable[Dict, AIMessage]: The simulated user for chatbot simulation.
|
||||
"""
|
||||
return ChatPromptTemplate.from_messages(
|
||||
[
|
||||
("system", system_prompt),
|
||||
MessagesPlaceholder(variable_name="messages"),
|
||||
]
|
||||
) | (llm or ChatOpenAI(model="gpt-3.5-turbo")).with_config(
|
||||
run_name="simulated_user"
|
||||
)
|
||||
|
||||
|
||||
Messages = Union[list[AnyMessage], AnyMessage]
|
||||
|
||||
|
||||
def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
if not isinstance(left, list):
|
||||
left = [left]
|
||||
if not isinstance(right, list):
|
||||
right = [right]
|
||||
return left + right
|
||||
|
||||
|
||||
class SimulationState(TypedDict):
|
||||
"""
|
||||
Represents the state of a simulation.
|
||||
|
||||
Attributes:
|
||||
messages (List[AnyMessage]): A list of messages in the simulation.
|
||||
inputs (Optional[dict[str, Any]]): Optional inputs for the simulation.
|
||||
"""
|
||||
|
||||
messages: Annotated[List[AnyMessage], add_messages]
|
||||
inputs: Optional[dict[str, Any]]
|
||||
|
||||
|
||||
def create_chat_simulator(
|
||||
assistant: (
|
||||
Callable[[List[AnyMessage]], str | AIMessage]
|
||||
| Runnable[List[AnyMessage], str | AIMessage]
|
||||
),
|
||||
simulated_user: Runnable[Dict, AIMessage],
|
||||
*,
|
||||
input_key: str,
|
||||
max_turns: int = 6,
|
||||
should_continue: Optional[Callable[[SimulationState], str]] = None,
|
||||
):
|
||||
"""Creates a chat simulator for evaluating a chatbot.
|
||||
|
||||
Args:
|
||||
assistant: The chatbot assistant function or runnable object.
|
||||
simulated_user: The simulated user object.
|
||||
input_key: The key for the input to the chat simulation.
|
||||
max_turns: The maximum number of turns in the chat simulation. Default is 6.
|
||||
should_continue: Optional function to determine if the simulation should continue.
|
||||
If not provided, a default function will be used.
|
||||
|
||||
Returns:
|
||||
The compiled chat simulation graph.
|
||||
|
||||
"""
|
||||
graph_builder = StateGraph(SimulationState)
|
||||
graph_builder.add_node(
|
||||
"user",
|
||||
_create_simulated_user_node(simulated_user),
|
||||
)
|
||||
graph_builder.add_node(
|
||||
"assistant", _fetch_messages | assistant | _coerce_to_message
|
||||
)
|
||||
graph_builder.add_edge("assistant", "user")
|
||||
graph_builder.add_conditional_edges(
|
||||
"user",
|
||||
should_continue or functools.partial(_should_continue, max_turns=max_turns),
|
||||
)
|
||||
# If your dataset has a 'leading question/input', then we route first to the assistant, otherwise, we let the user take the lead.
|
||||
graph_builder.add_edge(START, "assistant" if input_key is not None else "user")
|
||||
|
||||
return (
|
||||
RunnableLambda(_prepare_example).bind(input_key=input_key)
|
||||
| graph_builder.compile()
|
||||
)
|
||||
|
||||
|
||||
## Private methods
|
||||
|
||||
|
||||
def _prepare_example(inputs: dict[str, Any], input_key: Optional[str] = None):
|
||||
if input_key is not None:
|
||||
if input_key not in inputs:
|
||||
raise ValueError(
|
||||
f"Dataset's example input must contain the provided input key: '{input_key}'.\nFound: {list(inputs.keys())}"
|
||||
)
|
||||
messages = [HumanMessage(content=inputs[input_key])]
|
||||
return {
|
||||
"inputs": {k: v for k, v in inputs.items() if k != input_key},
|
||||
"messages": messages,
|
||||
}
|
||||
return {"inputs": inputs, "messages": []}
|
||||
|
||||
|
||||
def _invoke_simulated_user(state: SimulationState, simulated_user: Runnable):
|
||||
"""Invoke the simulated user node."""
|
||||
runnable = (
|
||||
simulated_user
|
||||
if isinstance(simulated_user, Runnable)
|
||||
else RunnableLambda(simulated_user)
|
||||
)
|
||||
inputs = state.get("inputs", {})
|
||||
inputs["messages"] = state["messages"]
|
||||
return runnable.invoke(inputs)
|
||||
|
||||
|
||||
def _swap_roles(state: SimulationState):
|
||||
new_messages = []
|
||||
for m in state["messages"]:
|
||||
if isinstance(m, AIMessage):
|
||||
new_messages.append(HumanMessage(content=m.content))
|
||||
else:
|
||||
new_messages.append(AIMessage(content=m.content))
|
||||
return {
|
||||
"inputs": state.get("inputs", {}),
|
||||
"messages": new_messages,
|
||||
}
|
||||
|
||||
|
||||
@as_runnable
|
||||
def _fetch_messages(state: SimulationState):
|
||||
"""Invoke the simulated user node."""
|
||||
return state["messages"]
|
||||
|
||||
|
||||
def _convert_to_human_message(message: BaseMessage):
|
||||
return {"messages": [HumanMessage(content=message.content)]}
|
||||
|
||||
|
||||
def _create_simulated_user_node(simulated_user: Runnable):
|
||||
"""Simulated user accepts a {"messages": [...]} argument and returns a single message."""
|
||||
return (
|
||||
_swap_roles
|
||||
| RunnableLambda(_invoke_simulated_user).bind(simulated_user=simulated_user)
|
||||
| _convert_to_human_message
|
||||
)
|
||||
|
||||
|
||||
def _coerce_to_message(assistant_output: str | BaseMessage):
|
||||
if isinstance(assistant_output, str):
|
||||
return {"messages": [AIMessage(content=assistant_output)]}
|
||||
else:
|
||||
return {"messages": [assistant_output]}
|
||||
|
||||
|
||||
def _should_continue(state: SimulationState, max_turns: int = 6):
|
||||
messages = state["messages"]
|
||||
# TODO support other stop criteria
|
||||
if len(messages) > max_turns:
|
||||
return END
|
||||
elif messages[-1].content.strip() == "FINISHED":
|
||||
return END
|
||||
else:
|
||||
return "assistant"
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a9014f94",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/chatbots/information-gather-prompting.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f47ce992",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1f2f13ca",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/code_assistant/langgraph_code_assistant.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5e4c9bfe",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a8232bc9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/customer-support/customer-support.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "63da8671",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "8dbdba5b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/extraction/retries.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1d444b7f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "3ecab357",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/human_in_the_loop/wait-user-input.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "3f2866bd",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "09038b53",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/lats/lats.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b1669748",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "85205e97",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/llm-compiler/LLMCompiler.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2fdab366",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5cc8a2ad",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/multi_agent/hierarchical_agent_teams.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b9f3508a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d2b507b9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/multi_agent/multi-agent-collaboration.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "41a8f10a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9138f92e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/plan-and-execute/plan-and-execute.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "093678ba",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
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
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
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
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "294995c4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/react-agent-from-scratch.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "40f0d107",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/react-agent-structured-output.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "658773a2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/reflection/reflection.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1cb60657",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "caf07859",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/reflexion/reflexion.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cd1df0e0",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "961f43ec",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/rewoo/rewoo.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7f00c427",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "bbd6e9b8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/run-id-langsmith.md)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f6db1873",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/self-discover/self-discover.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "219a78f9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f49876e1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/subgraph.md)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7fd8bd65",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/how-tos/tool-calling.md)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "83c2223f",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/sql/sql-agent.md)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "57f924b1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "11140167",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/tnt-llm/tnt-llm.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1a2ba3e6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "9dffdb54",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/usaco/usaco.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "579c9959",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "007ea2e9",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"[This file has been moved](https://github.com/langchain-ai/langgraph/blob/23961cff61a42b52525f3b20b4094d8d2fba1744/docs/docs/tutorials/web-navigation/web_voyager.ipynb)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f0d7b895",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"This directory is retained purely for archival purposes and is no longer updated. The examples previously found here have been moved to the newly [consolidated LangChain documentation](https://docs.langchain.com/oss/python/langgraph/overview)."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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.11.2"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package advancedgraph
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type nodeExecutor func(ctx *Context, input any, state map[string]any) (Command, error)
|
||||
|
||||
type nodeConfig struct {
|
||||
lockedFields []string
|
||||
}
|
||||
|
||||
type NodeOption interface {
|
||||
applyToNodeConfig(*nodeConfig)
|
||||
}
|
||||
|
||||
type NodeStateOption struct {
|
||||
LockedFields []string
|
||||
}
|
||||
|
||||
func (o NodeStateOption) applyToNodeConfig(cfg *nodeConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.lockedFields = append(cfg.lockedFields[:0], o.LockedFields...)
|
||||
}
|
||||
|
||||
type AdvancedStateGraph[StateT any] struct {
|
||||
nodes map[string]nodeExecutor
|
||||
nodeOptions map[string]nodeConfig
|
||||
asyncChannels []string
|
||||
customStreams []string
|
||||
entryPoint string
|
||||
finishPoint string
|
||||
stateType reflect.Type
|
||||
}
|
||||
|
||||
func NewAdvancedStateGraph[StateT any]() *AdvancedStateGraph[StateT] {
|
||||
stateType := mustTypeOf[StateT]()
|
||||
if stateType.Kind() != reflect.Struct {
|
||||
panic(fmt.Sprintf("StateT must be a struct, got %s", stateType.String()))
|
||||
}
|
||||
return &AdvancedStateGraph[StateT]{
|
||||
nodes: make(map[string]nodeExecutor),
|
||||
nodeOptions: make(map[string]nodeConfig),
|
||||
stateType: stateType,
|
||||
}
|
||||
}
|
||||
|
||||
// AddNode keeps `fn` as `any` because advanced graph nodes can have different
|
||||
// input argument types per node, while only `StateT` is globally constrained.
|
||||
// We validate and adapt node signatures at runtime in compileNodeExecutor.
|
||||
func (g *AdvancedStateGraph[StateT]) AddNode(fn any, nodeOption ...NodeOption) string {
|
||||
name := NodeName(fn)
|
||||
return g.AddNodeAs(name, fn, nodeOption...)
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddNodeAs(name string, fn any, nodeOption ...NodeOption) string {
|
||||
if _, exists := g.nodes[name]; exists {
|
||||
panic(fmt.Sprintf("node `%s` already exists", name))
|
||||
}
|
||||
exec, err := compileNodeExecutor(fn, g.stateType)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.nodes[name] = exec
|
||||
g.nodeOptions[name] = resolveNodeConfig(nodeOption...)
|
||||
return name
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddAsyncChannel(name string) {
|
||||
g.asyncChannels = append(g.asyncChannels, name)
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddCustomOutputStream(name string) {
|
||||
g.customStreams = append(g.customStreams, name)
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddEntryNode(fn any, nodeOption ...NodeOption) string {
|
||||
name := NodeName(fn)
|
||||
return g.AddEntryNodeAs(name, fn, nodeOption...)
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddEntryNodeAs(name string, fn any, nodeOption ...NodeOption) string {
|
||||
name = g.AddNodeAs(name, fn, nodeOption...)
|
||||
g.entryPoint = name
|
||||
return name
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddFinishNode(fn any, nodeOption ...NodeOption) string {
|
||||
name := NodeName(fn)
|
||||
return g.AddFinishNodeAs(name, fn, nodeOption...)
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) AddFinishNodeAs(name string, fn any, nodeOption ...NodeOption) string {
|
||||
name = g.AddNodeAs(name, fn, nodeOption...)
|
||||
g.finishPoint = name
|
||||
return name
|
||||
}
|
||||
|
||||
func (g *AdvancedStateGraph[StateT]) Compile() *CompiledGraph[StateT] {
|
||||
return &CompiledGraph[StateT]{
|
||||
nodes: g.nodes,
|
||||
nodeOptions: g.nodeOptions,
|
||||
asyncChannels: g.asyncChannels,
|
||||
customStreams: g.customStreams,
|
||||
entryPoint: g.entryPoint,
|
||||
finishPoint: g.finishPoint,
|
||||
stateType: g.stateType,
|
||||
}
|
||||
}
|
||||
|
||||
type CompiledGraph[StateT any] struct {
|
||||
nodes map[string]nodeExecutor
|
||||
nodeOptions map[string]nodeConfig
|
||||
asyncChannels []string
|
||||
customStreams []string
|
||||
entryPoint string
|
||||
finishPoint string
|
||||
stateType reflect.Type
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
engine *RustEngine
|
||||
resumeEvent *WaitEvent
|
||||
isResume bool
|
||||
}
|
||||
|
||||
func (c *Context) WaitFor(target WaitTarget) (WaitForResult, error) {
|
||||
if target == nil {
|
||||
return WaitForResult{}, fmt.Errorf("wait target cannot be nil")
|
||||
}
|
||||
if c.resumeEvent != nil {
|
||||
event := *c.resumeEvent
|
||||
c.resumeEvent = nil
|
||||
return waitForResultFromRaw(target, event), nil
|
||||
}
|
||||
return WaitForResult{}, ErrWaitRequested{Target: target}
|
||||
}
|
||||
|
||||
func (c *Context) IsResume() bool {
|
||||
return c.isResume
|
||||
}
|
||||
|
||||
func (c *Context) PublishToChannel(channel string, value any) error {
|
||||
return c.engine.Publish(channel, value)
|
||||
}
|
||||
|
||||
func (c *Context) SendCustomStreamEvent(streamName string, value any) error {
|
||||
return c.engine.SendCustomStreamEvent(streamName, value)
|
||||
}
|
||||
|
||||
type Handler[StateT any] struct {
|
||||
engine *RustEngine
|
||||
done chan resultOrErr[StateT]
|
||||
streamReadyC chan struct{}
|
||||
}
|
||||
|
||||
type resultOrErr[StateT any] struct {
|
||||
state StateT
|
||||
err error
|
||||
}
|
||||
|
||||
func (h *Handler[StateT]) PublishToChannel(channel string, value any) error {
|
||||
return h.engine.Publish(channel, value)
|
||||
}
|
||||
|
||||
func (h *Handler[StateT]) WaitForResult() (StateT, error) {
|
||||
res := <-h.done
|
||||
return res.state, res.err
|
||||
}
|
||||
|
||||
func (h *Handler[StateT]) ReceiveStream(streamName string) (any, error) {
|
||||
<-h.streamReadyC
|
||||
event, hasEvent, err := h.engine.ReceiveStream(streamName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasEvent {
|
||||
return nil, nil
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (h *Handler[StateT]) CloseAllStreams() error {
|
||||
<-h.streamReadyC
|
||||
return h.engine.CloseAllStreams()
|
||||
}
|
||||
|
||||
func (g *CompiledGraph[StateT]) Start(initialInput any, initialState StateT, streamMode ...string) (*Handler[StateT], error) {
|
||||
resolvedStreamMode := ""
|
||||
if len(streamMode) > 1 {
|
||||
return nil, fmt.Errorf("start accepts at most one stream mode")
|
||||
}
|
||||
if len(streamMode) == 1 {
|
||||
resolvedStreamMode = streamMode[0]
|
||||
}
|
||||
engine := NewRustEngine()
|
||||
for _, ch := range g.asyncChannels {
|
||||
if err := engine.AddAsyncChannel(ch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, streamName := range g.customStreams {
|
||||
if err := engine.AddCustomOutputStream(streamName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
handler := &Handler[StateT]{
|
||||
engine: engine,
|
||||
done: make(chan resultOrErr[StateT], 1),
|
||||
streamReadyC: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
defer engine.Close()
|
||||
streamModeForRun := resolvedStreamMode
|
||||
if resolvedStreamMode != "" {
|
||||
if err := engine.StartStream(resolvedStreamMode); err != nil {
|
||||
close(handler.streamReadyC)
|
||||
handler.done <- resultOrErr[StateT]{err: err}
|
||||
close(handler.done)
|
||||
return
|
||||
}
|
||||
streamModeForRun = ""
|
||||
}
|
||||
close(handler.streamReadyC)
|
||||
rawState, err := engine.RunGraph(
|
||||
g.entryPoint,
|
||||
g.finishPoint,
|
||||
streamModeForRun,
|
||||
initialState,
|
||||
initialInput,
|
||||
g.nodeLockedFields(),
|
||||
func(node string, nodeInput any, fallbackState map[string]any) (Command, error) {
|
||||
fn, ok := g.nodes[node]
|
||||
if !ok {
|
||||
return Command{}, fmt.Errorf("unknown node `%s`", node)
|
||||
}
|
||||
if fallbackState == nil {
|
||||
return Command{}, fmt.Errorf("node `%s` expected map state argument", node)
|
||||
}
|
||||
resolvedInput, resumeEvent := unwrapResumeInput(nodeInput)
|
||||
return fn(
|
||||
&Context{
|
||||
engine: engine,
|
||||
resumeEvent: resumeEvent,
|
||||
isResume: resumeEvent != nil,
|
||||
},
|
||||
resolvedInput,
|
||||
fallbackState,
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
handler.done <- resultOrErr[StateT]{err: err}
|
||||
close(handler.done)
|
||||
return
|
||||
}
|
||||
state, err := mapToState[StateT](rawState)
|
||||
handler.done <- resultOrErr[StateT]{state: state, err: err}
|
||||
close(handler.done)
|
||||
}()
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
func (g *CompiledGraph[StateT]) nodeLockedFields() map[string][]string {
|
||||
result := make(map[string][]string, len(g.nodeOptions))
|
||||
for nodeName, option := range g.nodeOptions {
|
||||
if len(option.lockedFields) == 0 {
|
||||
continue
|
||||
}
|
||||
fields := make([]string, 0, len(option.lockedFields))
|
||||
for _, field := range option.lockedFields {
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
fields = append(fields, field)
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
result[nodeName] = fields
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveNodeConfig(nodeOption ...NodeOption) nodeConfig {
|
||||
cfg := nodeConfig{}
|
||||
for _, option := range nodeOption {
|
||||
if option == nil {
|
||||
panic("node option cannot be nil")
|
||||
}
|
||||
option.applyToNodeConfig(&cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func NodeName(fn any) string {
|
||||
rv := reflect.ValueOf(fn)
|
||||
if !rv.IsValid() || rv.Kind() != reflect.Func {
|
||||
panic("cannot infer node name from non-function value")
|
||||
}
|
||||
pc := rv.Pointer()
|
||||
f := runtime.FuncForPC(pc)
|
||||
if f == nil {
|
||||
panic("cannot infer node name from nil function")
|
||||
}
|
||||
full := f.Name()
|
||||
if strings.Contains(full, ".func") {
|
||||
panic("anonymous functions are not allowed as nodes")
|
||||
}
|
||||
short := full
|
||||
if i := strings.LastIndex(short, "/"); i >= 0 {
|
||||
short = short[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(short, "."); i >= 0 {
|
||||
short = short[i+1:]
|
||||
}
|
||||
short = strings.TrimSuffix(short, "-fm")
|
||||
if short == "" || strings.Contains(short, "func") {
|
||||
panic(fmt.Sprintf("cannot infer stable node name from `%s`", full))
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func compileNodeExecutor(fn any, expectedStateType reflect.Type) (nodeExecutor, error) {
|
||||
rv := reflect.ValueOf(fn)
|
||||
if !rv.IsValid() || rv.Kind() != reflect.Func {
|
||||
return nil, fmt.Errorf("node must be a function")
|
||||
}
|
||||
rt := rv.Type()
|
||||
if rt.NumIn() != 3 {
|
||||
return nil, fmt.Errorf("node `%s` must accept exactly 3 args: (*Context, input, state)", NodeName(fn))
|
||||
}
|
||||
ctxType := reflect.TypeOf((*Context)(nil))
|
||||
if rt.In(0) != ctxType {
|
||||
return nil, fmt.Errorf("node `%s` first arg must be *Context", NodeName(fn))
|
||||
}
|
||||
if rt.NumOut() != 2 {
|
||||
return nil, fmt.Errorf("node `%s` must return (Command, error)", NodeName(fn))
|
||||
}
|
||||
cmdType := reflect.TypeOf(Command{})
|
||||
if rt.Out(0) != cmdType {
|
||||
return nil, fmt.Errorf("node `%s` first return must be Command", NodeName(fn))
|
||||
}
|
||||
errType := reflect.TypeOf((*error)(nil)).Elem()
|
||||
if !rt.Out(1).Implements(errType) {
|
||||
return nil, fmt.Errorf("node `%s` second return must be error", NodeName(fn))
|
||||
}
|
||||
|
||||
inputType := rt.In(1)
|
||||
stateType := rt.In(2)
|
||||
if stateType != expectedStateType {
|
||||
return nil, fmt.Errorf(
|
||||
"node `%s` state type mismatch: got %s, graph expects %s",
|
||||
NodeName(fn),
|
||||
stateType.String(),
|
||||
expectedStateType.String(),
|
||||
)
|
||||
}
|
||||
return func(ctx *Context, input any, state map[string]any) (Command, error) {
|
||||
stateArg, err := convertStateArg(state, stateType)
|
||||
if err != nil {
|
||||
return Command{}, fmt.Errorf("node `%s` state decode failed: %w", NodeName(fn), err)
|
||||
}
|
||||
args := []reflect.Value{
|
||||
reflect.ValueOf(ctx),
|
||||
reflect.Zero(inputType),
|
||||
stateArg,
|
||||
}
|
||||
if input != nil {
|
||||
inVal := reflect.ValueOf(input)
|
||||
if inVal.Type().AssignableTo(inputType) {
|
||||
args[1] = inVal
|
||||
} else if inVal.Type().ConvertibleTo(inputType) {
|
||||
args[1] = inVal.Convert(inputType)
|
||||
} else {
|
||||
return Command{}, fmt.Errorf(
|
||||
"node `%s` input type mismatch: got %T, want %s",
|
||||
NodeName(fn),
|
||||
input,
|
||||
inputType.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
out := rv.Call(args)
|
||||
cmd := out[0].Interface().(Command)
|
||||
if cmd.Update != nil {
|
||||
updateType := reflect.TypeOf(cmd.Update)
|
||||
if updateType != stateType {
|
||||
return Command{}, fmt.Errorf(
|
||||
"node `%s` update type mismatch: got %s, graph expects %s",
|
||||
NodeName(fn),
|
||||
updateType.String(),
|
||||
stateType.String(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if out[1].IsNil() {
|
||||
return cmd, nil
|
||||
}
|
||||
return cmd, out[1].Interface().(error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func convertStateArg(state map[string]any, stateType reflect.Type) (reflect.Value, error) {
|
||||
if stateType == reflect.TypeOf(map[string]any{}) {
|
||||
return reflect.ValueOf(state), nil
|
||||
}
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
if stateType.Kind() == reflect.Ptr {
|
||||
target := reflect.New(stateType.Elem())
|
||||
if err := json.Unmarshal(raw, target.Interface()); err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("unmarshal state into %s: %w", stateType.String(), err)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
target := reflect.New(stateType)
|
||||
if err := json.Unmarshal(raw, target.Interface()); err != nil {
|
||||
return reflect.Value{}, fmt.Errorf("unmarshal state into %s: %w", stateType.String(), err)
|
||||
}
|
||||
return target.Elem(), nil
|
||||
}
|
||||
|
||||
func mapToState[StateT any](raw map[string]any) (StateT, error) {
|
||||
var out StateT
|
||||
if anyVal, ok := any(raw).(StateT); ok {
|
||||
return anyVal, nil
|
||||
}
|
||||
payload, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
return out, fmt.Errorf("unmarshal state: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mustTypeOf[T any]() reflect.Type {
|
||||
var zero T
|
||||
t := reflect.TypeOf(zero)
|
||||
if t != nil {
|
||||
return t
|
||||
}
|
||||
// Handles nil-able types where zero value has no dynamic type.
|
||||
return reflect.TypeOf((*T)(nil)).Elem()
|
||||
}
|
||||
|
||||
func unwrapResumeInput(input any) (any, *WaitEvent) {
|
||||
wrapper, ok := input.(map[string]any)
|
||||
if !ok {
|
||||
return input, nil
|
||||
}
|
||||
rawArg, hasArg := wrapper["__lg_resume_arg__"]
|
||||
rawEvent, hasEvent := wrapper["__lg_resume_event__"]
|
||||
if !hasArg || !hasEvent {
|
||||
return input, nil
|
||||
}
|
||||
eventPayload, err := json.Marshal(rawEvent)
|
||||
if err != nil {
|
||||
return rawArg, nil
|
||||
}
|
||||
var event WaitEvent
|
||||
if err := json.Unmarshal(eventPayload, &event); err != nil {
|
||||
return rawArg, nil
|
||||
}
|
||||
return rawArg, &event
|
||||
}
|
||||
|
||||
func waitForResultFromRaw(target WaitTarget, event WaitEvent) WaitForResult {
|
||||
conditions := target.waitConditions()
|
||||
result := WaitForResult{
|
||||
Conditions: make([]ConditionResult, len(conditions)),
|
||||
}
|
||||
if target.waitKind() == "all_of" {
|
||||
for i, cond := range conditions {
|
||||
if isTimerCondition(cond) {
|
||||
result.Conditions[i] = ConditionResult{Met: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.Condition == "timer" {
|
||||
for i, cond := range conditions {
|
||||
if isTimerCondition(cond) {
|
||||
result.Conditions[i] = ConditionResult{Met: true}
|
||||
if target.waitKind() == "any_of" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if event.Condition != "channel" {
|
||||
return result
|
||||
}
|
||||
|
||||
if event.Channel == "__any_of__" || event.Channel == "__all_of__" {
|
||||
var matched []struct {
|
||||
Channel string `json:"channel"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
_ = json.Unmarshal(event.Value, &matched)
|
||||
cursor := 0
|
||||
for i, cond := range conditions {
|
||||
channelName, ok := channelNameOfCondition(cond)
|
||||
if !ok || cursor >= len(matched) {
|
||||
continue
|
||||
}
|
||||
if channelName == matched[cursor].Channel {
|
||||
result.Conditions[i] = ConditionResult{
|
||||
Met: true,
|
||||
ChannelName: channelName,
|
||||
Values: toValues(matched[cursor].Value),
|
||||
}
|
||||
cursor++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var value any
|
||||
_ = json.Unmarshal(event.Value, &value)
|
||||
for i, cond := range conditions {
|
||||
channelName, ok := channelNameOfCondition(cond)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if channelName == event.Channel {
|
||||
result.Conditions[i] = ConditionResult{
|
||||
Met: true,
|
||||
ChannelName: channelName,
|
||||
Values: toValues(value),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isTimerCondition(cond WaitCondition) bool {
|
||||
switch cond.(type) {
|
||||
case TimerCondition, *TimerCondition:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func channelNameOfCondition(cond WaitCondition) (string, bool) {
|
||||
switch c := cond.(type) {
|
||||
case ChannelCondition:
|
||||
return c.Channel, true
|
||||
case *ChannelCondition:
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
return c.Channel, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func toValues(value any) []any {
|
||||
if value == nil {
|
||||
return []any{}
|
||||
}
|
||||
if vals, ok := value.([]any); ok {
|
||||
return vals
|
||||
}
|
||||
return []any{value}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package advancedgraph
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/../../rust-core/include
|
||||
#cgo LDFLAGS: -L${SRCDIR}/../../rust-core/target/debug -llanggraph_rust_core
|
||||
#include "langgraph_rust_core.h"
|
||||
#include <stdlib.h>
|
||||
extern char* goNodeCallback(unsigned long user_data, char* node, char* arg_json, char* state_json);
|
||||
static inline char* rc_run_graph_json_with_go_callback(
|
||||
Engine* ptr,
|
||||
const char* entry_point,
|
||||
const char* finish_point,
|
||||
const char* initial_state_json,
|
||||
const char* initial_input_json,
|
||||
const char* stream_mode,
|
||||
const char* node_locked_fields_json,
|
||||
unsigned long user_data
|
||||
) {
|
||||
return rc_run_graph_json(
|
||||
ptr,
|
||||
entry_point,
|
||||
finish_point,
|
||||
initial_state_json,
|
||||
initial_input_json,
|
||||
stream_mode,
|
||||
node_locked_fields_json,
|
||||
user_data,
|
||||
goNodeCallback
|
||||
);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type RustEngine struct {
|
||||
ptr *C.Engine
|
||||
}
|
||||
|
||||
type runGraphCallbackCtx struct {
|
||||
exec func(node string, nodeInput any, state map[string]any) (Command, error)
|
||||
}
|
||||
|
||||
var (
|
||||
callbackRegistryMu sync.RWMutex
|
||||
callbackRegistry = map[uint64]*runGraphCallbackCtx{}
|
||||
callbackNextID uint64
|
||||
)
|
||||
|
||||
func registerRunGraphCallbackCtx(ctx *runGraphCallbackCtx) uint64 {
|
||||
id := atomic.AddUint64(&callbackNextID, 1)
|
||||
callbackRegistryMu.Lock()
|
||||
callbackRegistry[id] = ctx
|
||||
callbackRegistryMu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func unregisterRunGraphCallbackCtx(id uint64) {
|
||||
callbackRegistryMu.Lock()
|
||||
delete(callbackRegistry, id)
|
||||
callbackRegistryMu.Unlock()
|
||||
}
|
||||
|
||||
func getRunGraphCallbackCtx(id uint64) (*runGraphCallbackCtx, bool) {
|
||||
callbackRegistryMu.RLock()
|
||||
ctx, ok := callbackRegistry[id]
|
||||
callbackRegistryMu.RUnlock()
|
||||
return ctx, ok
|
||||
}
|
||||
|
||||
//export goNodeCallback
|
||||
func goNodeCallback(userData C.ulong, node *C.char, argJSON *C.char, stateJSON *C.char) *C.char {
|
||||
ctx, ok := getRunGraphCallbackCtx(uint64(userData))
|
||||
if !ok {
|
||||
return cCallbackEnvelopeError("invalid callback context (possibly stale callback)")
|
||||
}
|
||||
|
||||
nodeName := C.GoString(node)
|
||||
|
||||
var nodeInput any
|
||||
if err := json.Unmarshal([]byte(C.GoString(argJSON)), &nodeInput); err != nil {
|
||||
return cCallbackEnvelopeError(fmt.Sprintf("decode arg failed for `%s`: %v", nodeName, err))
|
||||
}
|
||||
var state map[string]any
|
||||
if err := json.Unmarshal([]byte(C.GoString(stateJSON)), &state); err != nil {
|
||||
return cCallbackEnvelopeError(fmt.Sprintf("decode state failed for `%s`: %v", nodeName, err))
|
||||
}
|
||||
nodeInput = coerceJSONValue(nodeInput)
|
||||
stateAny := coerceJSONValue(state)
|
||||
state, ok = stateAny.(map[string]any)
|
||||
if !ok {
|
||||
return cCallbackEnvelopeError(fmt.Sprintf("decoded state has unexpected type for `%s`", nodeName))
|
||||
}
|
||||
|
||||
cmd, err := ctx.exec(nodeName, nodeInput, state)
|
||||
if err != nil {
|
||||
if waitReq, ok := AsErrWaitRequested(err); ok {
|
||||
return cCallbackEnvelopeSuspend(waitReq.Target)
|
||||
}
|
||||
return cCallbackEnvelopeError(err.Error())
|
||||
}
|
||||
|
||||
sends := make([]map[string]any, 0, len(cmd.Goto))
|
||||
for _, send := range cmd.Goto {
|
||||
targetNode, err := resolveSendTarget(send.Node)
|
||||
if err != nil {
|
||||
return cCallbackEnvelopeError(err.Error())
|
||||
}
|
||||
sends = append(sends, map[string]any{
|
||||
"node": targetNode,
|
||||
"arg": send.NodeInput,
|
||||
})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"update": cmd.Update,
|
||||
"sends": sends,
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"payload": payload,
|
||||
})
|
||||
if err != nil {
|
||||
return cCallbackEnvelopeError(fmt.Sprintf("encode callback payload failed: %v", err))
|
||||
}
|
||||
return C.CString(string(raw))
|
||||
}
|
||||
|
||||
func NewRustEngine() *RustEngine {
|
||||
return &RustEngine{ptr: C.rc_engine_new()}
|
||||
}
|
||||
|
||||
func (e *RustEngine) Close() {
|
||||
if e.ptr != nil {
|
||||
C.rc_engine_free(e.ptr)
|
||||
e.ptr = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *RustEngine) AddAsyncChannel(channel string) error {
|
||||
cch := C.CString(channel)
|
||||
defer C.free(unsafe.Pointer(cch))
|
||||
resp := C.rc_add_async_channel(e.ptr, cch)
|
||||
return parseRustStatus(resp)
|
||||
}
|
||||
|
||||
func (e *RustEngine) AddCustomOutputStream(streamName string) error {
|
||||
cname := C.CString(streamName)
|
||||
defer C.free(unsafe.Pointer(cname))
|
||||
resp := C.rc_add_custom_output_stream(e.ptr, cname)
|
||||
return parseRustStatus(resp)
|
||||
}
|
||||
|
||||
func (e *RustEngine) StartStream(streamMode string) error {
|
||||
var cmode *C.char
|
||||
if streamMode != "" {
|
||||
cmode = C.CString(streamMode)
|
||||
defer C.free(unsafe.Pointer(cmode))
|
||||
}
|
||||
resp := C.rc_start_stream(e.ptr, cmode)
|
||||
return parseRustStatus(resp)
|
||||
}
|
||||
|
||||
func (e *RustEngine) ReceiveStream(streamName string) (any, bool, error) {
|
||||
cname := C.CString(streamName)
|
||||
defer C.free(unsafe.Pointer(cname))
|
||||
resp := C.rc_receive_stream_json(e.ptr, cname)
|
||||
defer C.rc_string_free(resp)
|
||||
|
||||
raw := C.GoString(resp)
|
||||
var status struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
HasEvent bool `json:"has_event"`
|
||||
Event json.RawMessage `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return nil, false, fmt.Errorf("decode rust stream response: %w", err)
|
||||
}
|
||||
if !status.OK {
|
||||
return nil, false, fmt.Errorf("rust stream failed: %s", status.Error)
|
||||
}
|
||||
if !status.HasEvent {
|
||||
return nil, false, nil
|
||||
}
|
||||
var event any
|
||||
if err := json.Unmarshal(status.Event, &event); err != nil {
|
||||
return nil, false, fmt.Errorf("decode stream event: %w", err)
|
||||
}
|
||||
return coerceJSONValue(event), true, nil
|
||||
}
|
||||
|
||||
func (e *RustEngine) SendCustomStreamEvent(streamName string, value any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal stream event: %w", err)
|
||||
}
|
||||
cname := C.CString(streamName)
|
||||
cval := C.CString(string(payload))
|
||||
defer C.free(unsafe.Pointer(cname))
|
||||
defer C.free(unsafe.Pointer(cval))
|
||||
resp := C.rc_send_custom_stream_event(e.ptr, cname, cval)
|
||||
return parseRustStatus(resp)
|
||||
}
|
||||
|
||||
func (e *RustEngine) CloseAllStreams() error {
|
||||
resp := C.rc_close_all_streams(e.ptr)
|
||||
return parseRustStatus(resp)
|
||||
}
|
||||
|
||||
func (e *RustEngine) Publish(channel string, value any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal publish value: %w", err)
|
||||
}
|
||||
cch := C.CString(channel)
|
||||
cval := C.CString(string(payload))
|
||||
defer C.free(unsafe.Pointer(cch))
|
||||
defer C.free(unsafe.Pointer(cval))
|
||||
resp := C.rc_publish_json(e.ptr, cch, cval)
|
||||
return parseRustStatus(resp)
|
||||
}
|
||||
|
||||
func (e *RustEngine) WaitAnyOf(cond AnyOfCondition) (WaitEvent, error) {
|
||||
payload, err := json.Marshal(cond)
|
||||
if err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("marshal any_of: %w", err)
|
||||
}
|
||||
cpayload := C.CString(string(payload))
|
||||
defer C.free(unsafe.Pointer(cpayload))
|
||||
resp := C.rc_wait_any_of_json(e.ptr, cpayload)
|
||||
defer C.rc_string_free(resp)
|
||||
|
||||
raw := C.GoString(resp)
|
||||
var status struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Event json.RawMessage `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("decode rust wait response: %w", err)
|
||||
}
|
||||
if !status.OK {
|
||||
return WaitEvent{}, fmt.Errorf("rust wait failed: %s", status.Error)
|
||||
}
|
||||
var event WaitEvent
|
||||
if err := json.Unmarshal(status.Event, &event); err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("decode wait event: %w", err)
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (e *RustEngine) WaitAllOf(cond AllOfCondition) (WaitEvent, error) {
|
||||
payload, err := json.Marshal(cond)
|
||||
if err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("marshal all_of: %w", err)
|
||||
}
|
||||
cpayload := C.CString(string(payload))
|
||||
defer C.free(unsafe.Pointer(cpayload))
|
||||
resp := C.rc_wait_all_of_json(e.ptr, cpayload)
|
||||
defer C.rc_string_free(resp)
|
||||
|
||||
raw := C.GoString(resp)
|
||||
var status struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
Event json.RawMessage `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("decode rust wait response: %w", err)
|
||||
}
|
||||
if !status.OK {
|
||||
return WaitEvent{}, fmt.Errorf("rust wait failed: %s", status.Error)
|
||||
}
|
||||
var event WaitEvent
|
||||
if err := json.Unmarshal(status.Event, &event); err != nil {
|
||||
return WaitEvent{}, fmt.Errorf("decode wait event: %w", err)
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (e *RustEngine) RunGraph(
|
||||
entryPoint string,
|
||||
finishPoint string,
|
||||
streamMode string,
|
||||
initialState any,
|
||||
initialInput any,
|
||||
nodeLockedFields map[string][]string,
|
||||
exec func(node string, nodeInput any, state map[string]any) (Command, error),
|
||||
) (map[string]any, error) {
|
||||
initialJSON, err := json.Marshal(initialState)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal initial state: %w", err)
|
||||
}
|
||||
initialInputJSON, err := json.Marshal(initialInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal initial input: %w", err)
|
||||
}
|
||||
lockedFieldsJSON, err := json.Marshal(nodeLockedFields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal node locked fields: %w", err)
|
||||
}
|
||||
centry := C.CString(entryPoint)
|
||||
cfinish := C.CString(finishPoint)
|
||||
cinitial := C.CString(string(initialJSON))
|
||||
cinitialInput := C.CString(string(initialInputJSON))
|
||||
clockedFields := C.CString(string(lockedFieldsJSON))
|
||||
var cstreamMode *C.char
|
||||
if streamMode != "" {
|
||||
cstreamMode = C.CString(streamMode)
|
||||
}
|
||||
defer C.free(unsafe.Pointer(centry))
|
||||
defer C.free(unsafe.Pointer(cfinish))
|
||||
defer C.free(unsafe.Pointer(cinitial))
|
||||
defer C.free(unsafe.Pointer(cinitialInput))
|
||||
defer C.free(unsafe.Pointer(clockedFields))
|
||||
if cstreamMode != nil {
|
||||
defer C.free(unsafe.Pointer(cstreamMode))
|
||||
}
|
||||
|
||||
callbackID := registerRunGraphCallbackCtx(&runGraphCallbackCtx{exec: exec})
|
||||
defer unregisterRunGraphCallbackCtx(callbackID)
|
||||
|
||||
resp := C.rc_run_graph_json_with_go_callback(
|
||||
e.ptr,
|
||||
centry,
|
||||
cfinish,
|
||||
cinitial,
|
||||
cinitialInput,
|
||||
cstreamMode,
|
||||
clockedFields,
|
||||
C.ulong(callbackID),
|
||||
)
|
||||
defer C.rc_string_free(resp)
|
||||
|
||||
raw := C.GoString(resp)
|
||||
var status struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
State map[string]any `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return nil, fmt.Errorf("decode rust run response: %w", err)
|
||||
}
|
||||
if !status.OK {
|
||||
return nil, fmt.Errorf("rust run failed: %s", status.Error)
|
||||
}
|
||||
coerced := coerceJSONValue(status.State)
|
||||
typed, ok := coerced.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected state type from rust run")
|
||||
}
|
||||
return typed, nil
|
||||
}
|
||||
|
||||
func cCallbackEnvelopeError(message string) *C.char {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"ok": false,
|
||||
"error": message,
|
||||
})
|
||||
return C.CString(string(raw))
|
||||
}
|
||||
|
||||
func cCallbackEnvelopeSuspend(target WaitTarget) *C.char {
|
||||
if target == nil {
|
||||
return cCallbackEnvelopeError("wait requested with nil target")
|
||||
}
|
||||
kind := target.waitKind()
|
||||
if kind != "any_of" && kind != "all_of" {
|
||||
return cCallbackEnvelopeError(fmt.Sprintf("unsupported wait target kind `%s`", kind))
|
||||
}
|
||||
var payload map[string]any
|
||||
if kind == "any_of" {
|
||||
payload = map[string]any{
|
||||
"kind": "any_of",
|
||||
"any_of": AnyOfCondition{Conditions: target.waitConditions()},
|
||||
}
|
||||
} else {
|
||||
payload = map[string]any{
|
||||
"kind": "all_of",
|
||||
"all_of": AllOfCondition{Conditions: target.waitConditions()},
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"ok": true,
|
||||
"suspend": payload,
|
||||
})
|
||||
return C.CString(string(raw))
|
||||
}
|
||||
|
||||
func resolveSendTarget(target any) (string, error) {
|
||||
if name, ok := target.(string); ok {
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("send target cannot be empty string")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
rv := reflect.ValueOf(target)
|
||||
if rv.IsValid() && rv.Kind() == reflect.Func {
|
||||
return NodeName(target), nil
|
||||
}
|
||||
return "", fmt.Errorf("unsupported send target type %T", target)
|
||||
}
|
||||
|
||||
func coerceJSONValue(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = coerceJSONValue(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
coerced := make([]any, len(t))
|
||||
allStrings := true
|
||||
for i, val := range t {
|
||||
cv := coerceJSONValue(val)
|
||||
coerced[i] = cv
|
||||
if _, ok := cv.(string); !ok {
|
||||
allStrings = false
|
||||
}
|
||||
}
|
||||
if allStrings {
|
||||
out := make([]string, len(coerced))
|
||||
for i, item := range coerced {
|
||||
out[i] = item.(string)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return coerced
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func parseRustStatus(resp *C.char) error {
|
||||
defer C.rc_string_free(resp)
|
||||
raw := C.GoString(resp)
|
||||
var status struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &status); err != nil {
|
||||
return fmt.Errorf("decode rust response: %w", err)
|
||||
}
|
||||
if !status.OK {
|
||||
return fmt.Errorf("rust error: %s", status.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package advancedgraph
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type WaitCondition interface {
|
||||
json.Marshaler
|
||||
}
|
||||
|
||||
type ChannelCondition struct {
|
||||
Channel string
|
||||
Min int
|
||||
Max int
|
||||
}
|
||||
|
||||
type channelConditionJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
Channel string `json:"channel"`
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
}
|
||||
|
||||
func (c ChannelCondition) MarshalJSON() ([]byte, error) {
|
||||
min := c.Min
|
||||
if min <= 0 {
|
||||
min = 1
|
||||
}
|
||||
return json.Marshal(channelConditionJSON{
|
||||
Kind: "channel",
|
||||
Channel: c.Channel,
|
||||
Min: min,
|
||||
Max: c.Max,
|
||||
})
|
||||
}
|
||||
|
||||
type TimerCondition struct {
|
||||
Seconds float64
|
||||
}
|
||||
|
||||
type timerConditionJSON struct {
|
||||
Kind string `json:"kind"`
|
||||
Seconds float64 `json:"seconds"`
|
||||
}
|
||||
|
||||
func (t TimerCondition) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(timerConditionJSON{
|
||||
Kind: "timer",
|
||||
Seconds: t.Seconds,
|
||||
})
|
||||
}
|
||||
|
||||
type AnyOfCondition struct {
|
||||
Conditions []WaitCondition
|
||||
}
|
||||
|
||||
type AllOfCondition struct {
|
||||
Conditions []WaitCondition
|
||||
}
|
||||
|
||||
type WaitTarget interface {
|
||||
waitKind() string
|
||||
waitConditions() []WaitCondition
|
||||
}
|
||||
|
||||
func (a AnyOfCondition) waitKind() string {
|
||||
return "any_of"
|
||||
}
|
||||
|
||||
func (a AnyOfCondition) waitConditions() []WaitCondition {
|
||||
return a.Conditions
|
||||
}
|
||||
|
||||
func (a AllOfCondition) waitKind() string {
|
||||
return "all_of"
|
||||
}
|
||||
|
||||
func (a AllOfCondition) waitConditions() []WaitCondition {
|
||||
return a.Conditions
|
||||
}
|
||||
|
||||
func AnyOf(conditions ...WaitCondition) AnyOfCondition {
|
||||
return AnyOfCondition{Conditions: append([]WaitCondition{}, conditions...)}
|
||||
}
|
||||
|
||||
func AllOf(conditions ...WaitCondition) AllOfCondition {
|
||||
return AllOfCondition{Conditions: append([]WaitCondition{}, conditions...)}
|
||||
}
|
||||
|
||||
func (a AnyOfCondition) MarshalJSON() ([]byte, error) {
|
||||
result := struct {
|
||||
Conditions []json.RawMessage `json:"conditions"`
|
||||
}{
|
||||
Conditions: make([]json.RawMessage, 0, len(a.Conditions)),
|
||||
}
|
||||
for i, cond := range a.Conditions {
|
||||
if isNilWaitCondition(cond) {
|
||||
return nil, fmt.Errorf("any_of condition[%d] is nil", i)
|
||||
}
|
||||
raw, err := cond.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode any_of condition[%d]: %w", i, err)
|
||||
}
|
||||
result.Conditions = append(result.Conditions, json.RawMessage(raw))
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
func (a AllOfCondition) MarshalJSON() ([]byte, error) {
|
||||
result := struct {
|
||||
Conditions []json.RawMessage `json:"conditions"`
|
||||
}{
|
||||
Conditions: make([]json.RawMessage, 0, len(a.Conditions)),
|
||||
}
|
||||
for i, cond := range a.Conditions {
|
||||
if isNilWaitCondition(cond) {
|
||||
return nil, fmt.Errorf("all_of condition[%d] is nil", i)
|
||||
}
|
||||
raw, err := cond.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode all_of condition[%d]: %w", i, err)
|
||||
}
|
||||
result.Conditions = append(result.Conditions, json.RawMessage(raw))
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
func isNilWaitCondition(cond WaitCondition) bool {
|
||||
if cond == nil {
|
||||
return true
|
||||
}
|
||||
v := reflect.ValueOf(cond)
|
||||
switch v.Kind() {
|
||||
case reflect.Ptr, reflect.Interface, reflect.Slice, reflect.Map, reflect.Func:
|
||||
return v.IsNil()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type WaitEvent struct {
|
||||
Condition string `json:"condition"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Seconds float64 `json:"seconds,omitempty"`
|
||||
}
|
||||
|
||||
type ConditionResult struct {
|
||||
Met bool `json:"met"`
|
||||
ChannelName string `json:"channel_name,omitempty"`
|
||||
Values []any `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type WaitForResult struct {
|
||||
Conditions []ConditionResult `json:"conditions"`
|
||||
}
|
||||
|
||||
type Send struct {
|
||||
Node any
|
||||
NodeInput any
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
Update any
|
||||
Goto []Send
|
||||
}
|
||||
|
||||
type ErrWaitRequested struct {
|
||||
Target WaitTarget
|
||||
}
|
||||
|
||||
func (e ErrWaitRequested) Error() string {
|
||||
return "wait requested"
|
||||
}
|
||||
|
||||
func AsErrWaitRequested(err error) (ErrWaitRequested, bool) {
|
||||
var target ErrWaitRequested
|
||||
if !errors.As(err, &target) {
|
||||
return target, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
func DecodeString(raw json.RawMessage) string {
|
||||
var s string
|
||||
_ = json.Unmarshal(raw, &s)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/langchain-ai/langgraph/langgraph-go
|
||||
|
||||
go 1.25
|
||||
@@ -0,0 +1,329 @@
|
||||
package stategraph
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
ag "github.com/langchain-ai/langgraph/langgraph-go/advancedgraph"
|
||||
)
|
||||
|
||||
type StateNodeFunc[StateT any] func(ctx *Context, state StateT) (StateT, error)
|
||||
|
||||
const (
|
||||
internalBarrierChannel = "__stategraph_barrier"
|
||||
internalInterruptChannel = "__stategraph_interrupt"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
inner *ag.Context
|
||||
}
|
||||
|
||||
func (c *Context) Interrupt(name string) (any, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("interrupt name cannot be empty")
|
||||
}
|
||||
event, err := c.inner.WaitFor(ag.AnyOf(ag.ChannelCondition{
|
||||
Channel: internalInterruptChannel,
|
||||
Min: 1,
|
||||
}))
|
||||
if err != nil {
|
||||
if waitReq, ok := ag.AsErrWaitRequested(err); ok {
|
||||
return nil, errInterruptRequested{
|
||||
Name: name,
|
||||
Condition: waitReq.Target,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(event.Conditions) == 0 || !event.Conditions[0].Met {
|
||||
return nil, nil
|
||||
}
|
||||
values := event.Conditions[0].Values
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rawValue := values[0]
|
||||
valueBytes, err := json.Marshal(rawValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode interrupt `%s` value: %w", name, err)
|
||||
}
|
||||
|
||||
var payload interruptPayload
|
||||
if err := json.Unmarshal(valueBytes, &payload); err != nil {
|
||||
var value any
|
||||
if err := json.Unmarshal(valueBytes, &value); err != nil {
|
||||
return nil, fmt.Errorf("decode interrupt `%s` value: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
if payload.Name != "" && payload.Name != name {
|
||||
return nil, fmt.Errorf("interrupt name mismatch: expected `%s`, got `%s`", name, payload.Name)
|
||||
}
|
||||
if len(payload.Value) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(payload.Value, &value); err != nil {
|
||||
return nil, fmt.Errorf("decode interrupt `%s` payload: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
type errInterruptRequested struct {
|
||||
Name string
|
||||
Condition ag.WaitTarget
|
||||
}
|
||||
|
||||
func (e errInterruptRequested) Error() string {
|
||||
if e.Name == "" {
|
||||
return "interrupt requested"
|
||||
}
|
||||
return fmt.Sprintf("interrupt requested: %s", e.Name)
|
||||
}
|
||||
|
||||
func asErrInterruptRequested(err error) (errInterruptRequested, bool) {
|
||||
var target errInterruptRequested
|
||||
if !errors.As(err, &target) {
|
||||
return target, false
|
||||
}
|
||||
return target, true
|
||||
}
|
||||
|
||||
type BasicStateGraph[StateT any] struct {
|
||||
nodes map[string]StateNodeFunc[StateT]
|
||||
edges map[string][]string
|
||||
}
|
||||
|
||||
type interruptPayload struct {
|
||||
Name string `json:"name"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
|
||||
func NewBasicStateGraph[StateT any]() *BasicStateGraph[StateT] {
|
||||
return &BasicStateGraph[StateT]{
|
||||
nodes: make(map[string]StateNodeFunc[StateT]),
|
||||
edges: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *BasicStateGraph[StateT]) AddNode(fn StateNodeFunc[StateT]) string {
|
||||
name := ag.NodeName(fn)
|
||||
if _, exists := g.nodes[name]; exists {
|
||||
panic(fmt.Sprintf("node `%s` already exists", name))
|
||||
}
|
||||
g.nodes[name] = fn
|
||||
return name
|
||||
}
|
||||
|
||||
func (g *BasicStateGraph[StateT]) AddEdge(from StateNodeFunc[StateT], to StateNodeFunc[StateT]) {
|
||||
fromName := ag.NodeName(from)
|
||||
toName := ag.NodeName(to)
|
||||
if _, ok := g.nodes[fromName]; !ok {
|
||||
panic(fmt.Sprintf("source node `%s` does not exist", fromName))
|
||||
}
|
||||
if _, ok := g.nodes[toName]; !ok {
|
||||
panic(fmt.Sprintf("target node `%s` does not exist", toName))
|
||||
}
|
||||
g.edges[fromName] = append(g.edges[fromName], toName)
|
||||
}
|
||||
|
||||
type CompiledBasicStateGraph[StateT any] struct {
|
||||
inner *ag.CompiledGraph[StateT]
|
||||
}
|
||||
|
||||
type Handler[StateT any] struct {
|
||||
inner *ag.Handler[StateT]
|
||||
}
|
||||
|
||||
func (h *Handler[StateT]) WaitForResult() (StateT, error) {
|
||||
return h.inner.WaitForResult()
|
||||
}
|
||||
|
||||
func (h *Handler[StateT]) Resume(name string, value any) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("interrupt name cannot be empty")
|
||||
}
|
||||
return h.inner.PublishToChannel(internalInterruptChannel, map[string]any{
|
||||
"name": name,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
|
||||
func (g *BasicStateGraph[StateT]) Compile() *CompiledBasicStateGraph[StateT] {
|
||||
if len(g.nodes) == 0 {
|
||||
panic("graph has no nodes")
|
||||
}
|
||||
levels, err := g.computeSupersteps()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
adv := ag.NewAdvancedStateGraph[StateT]()
|
||||
adv.AddAsyncChannel(internalBarrierChannel)
|
||||
adv.AddAsyncChannel(internalInterruptChannel)
|
||||
|
||||
const finalNodeName = "__stategraph_finish"
|
||||
finalNode := func(_ *ag.Context, _ any, state StateT) (ag.Command, error) {
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
adv.AddFinishNodeAs(finalNodeName, finalNode)
|
||||
|
||||
for stepIdx, stepNodes := range levels {
|
||||
for _, nodeName := range stepNodes {
|
||||
userFn := g.nodes[nodeName]
|
||||
nextBarrier := fmt.Sprintf("__stategraph_barrier_%d", stepIdx+1)
|
||||
wrapper := func(ctx *ag.Context, _ any, state StateT) (ag.Command, error) {
|
||||
updated, err := userFn(&Context{inner: ctx}, state)
|
||||
if err != nil {
|
||||
if interruptReq, ok := asErrInterruptRequested(err); ok {
|
||||
cond := interruptReq.Condition
|
||||
if cond == nil {
|
||||
cond = ag.AnyOf(ag.ChannelCondition{
|
||||
Channel: internalInterruptChannel,
|
||||
Min: 1,
|
||||
})
|
||||
}
|
||||
return ag.Command{}, ag.ErrWaitRequested{Target: cond}
|
||||
}
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if err := ctx.PublishToChannel(internalBarrierChannel, map[string]any{
|
||||
"step": stepIdx,
|
||||
}); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: updated,
|
||||
Goto: []ag.Send{{Node: nextBarrier}},
|
||||
}, nil
|
||||
}
|
||||
adv.AddNodeAs(fmt.Sprintf("__stategraph_node_%s", nodeName), wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
lastBarrier := len(levels)
|
||||
for barrierStep := 0; barrierStep <= lastBarrier; barrierStep++ {
|
||||
barrierName := fmt.Sprintf("__stategraph_barrier_%d", barrierStep)
|
||||
nextStep := barrierStep
|
||||
barrier := func(ctx *ag.Context, _ any, state StateT) (ag.Command, error) {
|
||||
if nextStep > 0 {
|
||||
needed := len(levels[nextStep-1])
|
||||
_, err := ctx.WaitFor(ag.AnyOf(ag.ChannelCondition{
|
||||
Channel: internalBarrierChannel,
|
||||
Min: needed,
|
||||
}))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
}
|
||||
if nextStep >= len(levels) {
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{{Node: finalNodeName}},
|
||||
}, nil
|
||||
}
|
||||
sends := make([]ag.Send, 0, len(levels[nextStep]))
|
||||
for _, nodeName := range levels[nextStep] {
|
||||
sends = append(sends, ag.Send{
|
||||
Node: fmt.Sprintf("__stategraph_node_%s", nodeName),
|
||||
})
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: sends,
|
||||
}, nil
|
||||
}
|
||||
if barrierStep == 0 {
|
||||
adv.AddEntryNodeAs(barrierName, barrier)
|
||||
} else {
|
||||
adv.AddNodeAs(barrierName, barrier)
|
||||
}
|
||||
}
|
||||
|
||||
return &CompiledBasicStateGraph[StateT]{
|
||||
inner: adv.Compile(),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *CompiledBasicStateGraph[StateT]) Start(initialState StateT) (*Handler[StateT], error) {
|
||||
raw, err := g.inner.Start(nil, initialState)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Handler[StateT]{inner: raw}, nil
|
||||
}
|
||||
|
||||
func (g *CompiledBasicStateGraph[StateT]) Invoke(initialState StateT) (StateT, error) {
|
||||
handler, err := g.Start(initialState)
|
||||
if err != nil {
|
||||
var zero StateT
|
||||
return zero, err
|
||||
}
|
||||
return handler.WaitForResult()
|
||||
}
|
||||
|
||||
func (g *BasicStateGraph[StateT]) computeSupersteps() ([][]string, error) {
|
||||
indegree := make(map[string]int, len(g.nodes))
|
||||
for name := range g.nodes {
|
||||
indegree[name] = 0
|
||||
}
|
||||
for from, tos := range g.edges {
|
||||
if _, ok := g.nodes[from]; !ok {
|
||||
return nil, fmt.Errorf("edge source `%s` does not exist", from)
|
||||
}
|
||||
for _, to := range tos {
|
||||
if _, ok := g.nodes[to]; !ok {
|
||||
return nil, fmt.Errorf("edge target `%s` does not exist", to)
|
||||
}
|
||||
indegree[to]++
|
||||
}
|
||||
}
|
||||
|
||||
queue := make([]string, 0, len(g.nodes))
|
||||
level := make(map[string]int, len(g.nodes))
|
||||
for name, deg := range indegree {
|
||||
if deg == 0 {
|
||||
queue = append(queue, name)
|
||||
}
|
||||
}
|
||||
if len(queue) == 0 {
|
||||
return nil, fmt.Errorf("graph has no entry nodes (cycle suspected)")
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for len(queue) > 0 {
|
||||
curr := queue[0]
|
||||
queue = queue[1:]
|
||||
processed++
|
||||
currLevel := level[curr]
|
||||
for _, to := range g.edges[curr] {
|
||||
if level[to] < currLevel+1 {
|
||||
level[to] = currLevel + 1
|
||||
}
|
||||
indegree[to]--
|
||||
if indegree[to] == 0 {
|
||||
queue = append(queue, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
if processed != len(g.nodes) {
|
||||
return nil, fmt.Errorf("graph contains a cycle")
|
||||
}
|
||||
|
||||
maxLevel := 0
|
||||
for _, lv := range level {
|
||||
if lv > maxLevel {
|
||||
maxLevel = lv
|
||||
}
|
||||
}
|
||||
levels := make([][]string, maxLevel+1)
|
||||
for nodeName := range g.nodes {
|
||||
lv := level[nodeName]
|
||||
levels[lv] = append(levels[lv], nodeName)
|
||||
}
|
||||
for i := range levels {
|
||||
slices.Sort(levels[i])
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package stategraph_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sg "github.com/langchain-ai/langgraph/langgraph-go/stategraph"
|
||||
)
|
||||
|
||||
type stateGraphState struct {
|
||||
Noop bool `json:"noop"`
|
||||
}
|
||||
|
||||
type orderRecorder struct {
|
||||
mu sync.Mutex
|
||||
orders map[string]int32
|
||||
seq int32
|
||||
}
|
||||
|
||||
func newOrderRecorder() *orderRecorder {
|
||||
return &orderRecorder{orders: make(map[string]int32)}
|
||||
}
|
||||
|
||||
func (f *orderRecorder) record(name string) {
|
||||
idx := atomic.AddInt32(&f.seq, 1)
|
||||
f.mu.Lock()
|
||||
f.orders[name] = idx
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
type orderGraph struct {
|
||||
recorder *orderRecorder
|
||||
}
|
||||
|
||||
func newOrderGraph() *orderGraph {
|
||||
return &orderGraph{
|
||||
recorder: newOrderRecorder(),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *orderGraph) A(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("A")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (g *orderGraph) B1(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("B1")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (g *orderGraph) B2(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("B2")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (g *orderGraph) C1(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("C1")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (g *orderGraph) C2(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("C2")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (g *orderGraph) C3(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("C3")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (g *orderGraph) D(_ *sg.Context, state stateGraphState) (stateGraphState, error) {
|
||||
g.recorder.record("D")
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func TestBasicStateGraphWithoutInterrupt(t *testing.T) {
|
||||
fixture := newOrderGraph()
|
||||
graph := sg.NewBasicStateGraph[stateGraphState]()
|
||||
graph.AddNode(fixture.A)
|
||||
graph.AddNode(fixture.B1)
|
||||
graph.AddNode(fixture.B2)
|
||||
graph.AddNode(fixture.C1)
|
||||
graph.AddNode(fixture.C2)
|
||||
graph.AddNode(fixture.C3)
|
||||
graph.AddNode(fixture.D)
|
||||
|
||||
graph.AddEdge(fixture.A, fixture.B1)
|
||||
graph.AddEdge(fixture.A, fixture.B2)
|
||||
graph.AddEdge(fixture.B1, fixture.C1)
|
||||
graph.AddEdge(fixture.B1, fixture.C2)
|
||||
graph.AddEdge(fixture.B2, fixture.C3)
|
||||
graph.AddEdge(fixture.C1, fixture.D)
|
||||
graph.AddEdge(fixture.C2, fixture.D)
|
||||
graph.AddEdge(fixture.C3, fixture.D)
|
||||
|
||||
_, err := graph.Compile().Invoke(stateGraphState{})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke failed: %v", err)
|
||||
}
|
||||
|
||||
fixture.recorder.mu.Lock()
|
||||
orders := make(map[string]int32, len(fixture.recorder.orders))
|
||||
for k, v := range fixture.recorder.orders {
|
||||
orders[k] = v
|
||||
}
|
||||
fixture.recorder.mu.Unlock()
|
||||
|
||||
for _, name := range []string{"A", "B1", "B2", "C1", "C2", "C3", "D"} {
|
||||
if _, ok := orders[name]; !ok {
|
||||
t.Fatalf("node %s did not execute; orders=%v", name, orders)
|
||||
}
|
||||
}
|
||||
maxB := maxInt32(orders["B1"], orders["B2"])
|
||||
minC := minInt32(orders["C1"], minInt32(orders["C2"], orders["C3"]))
|
||||
maxC := maxInt32(orders["C1"], maxInt32(orders["C2"], orders["C3"]))
|
||||
if !(orders["A"] < orders["B1"] && orders["A"] < orders["B2"]) {
|
||||
t.Fatalf("A should run before B-step, orders=%v", orders)
|
||||
}
|
||||
if !(maxB < minC) {
|
||||
t.Fatalf("B-step should finish before C-step, orders=%v", orders)
|
||||
}
|
||||
if !(maxC < orders["D"]) {
|
||||
t.Fatalf("C-step should finish before D, orders=%v", orders)
|
||||
}
|
||||
}
|
||||
|
||||
type interruptState struct {
|
||||
A bool `json:"a"`
|
||||
B bool `json:"b"`
|
||||
}
|
||||
|
||||
type interruptFixture struct{}
|
||||
|
||||
func (f *interruptFixture) A(_ *sg.Context, state interruptState) (interruptState, error) {
|
||||
state.A = true
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (f *interruptFixture) B(ctx *sg.Context, state interruptState) (interruptState, error) {
|
||||
if !state.A {
|
||||
return state, fmt.Errorf("B should observe A=true")
|
||||
}
|
||||
value, err := ctx.Interrupt("resume_channel")
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
s, ok := value.(string)
|
||||
if !ok || s != "go" {
|
||||
return state, fmt.Errorf("unexpected interrupt payload: %#v", value)
|
||||
}
|
||||
state.B = true
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func TestBasicStateGraphWithInterrupt(t *testing.T) {
|
||||
fixture := &interruptFixture{}
|
||||
graph := sg.NewBasicStateGraph[interruptState]()
|
||||
graph.AddNode(fixture.A)
|
||||
graph.AddNode(fixture.B)
|
||||
graph.AddEdge(fixture.A, fixture.B)
|
||||
|
||||
handler, err := graph.Compile().Start(interruptState{})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
doneCh := make(chan interruptState, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
result, runErr := handler.WaitForResult()
|
||||
if runErr != nil {
|
||||
errCh <- runErr
|
||||
return
|
||||
}
|
||||
doneCh <- result
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-doneCh:
|
||||
t.Fatalf("run should pause for interrupt, but completed early")
|
||||
case err := <-errCh:
|
||||
t.Fatalf("run should pause for interrupt, but failed early: %v", err)
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
// expected: paused
|
||||
}
|
||||
|
||||
if err := handler.Resume("resume_channel", "go"); err != nil {
|
||||
t.Fatalf("resume interrupt failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("run failed after interrupt: %v", err)
|
||||
case result := <-doneCh:
|
||||
if !(result.A && result.B) {
|
||||
t.Fatalf("unexpected final state: %#v", result)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for resumed run completion")
|
||||
}
|
||||
}
|
||||
|
||||
func minInt32(a int32, b int32) int32 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt32(a int32, b int32) int32 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ag "github.com/langchain-ai/langgraph/langgraph-go/advancedgraph"
|
||||
)
|
||||
|
||||
type decision struct {
|
||||
Type string
|
||||
SubAgent string
|
||||
Tool string
|
||||
Complete string
|
||||
}
|
||||
|
||||
type mockLLM struct {
|
||||
responses [][]decision
|
||||
i int
|
||||
}
|
||||
|
||||
func (m *mockLLM) invoke() []decision {
|
||||
if m.i >= len(m.responses) {
|
||||
return []decision{}
|
||||
}
|
||||
resp := m.responses[m.i]
|
||||
m.i++
|
||||
return resp
|
||||
}
|
||||
|
||||
type lunchWorkflow struct {
|
||||
planner *mockLLM
|
||||
}
|
||||
|
||||
type lunchState struct {
|
||||
Input string `json:"input"`
|
||||
Output []string `json:"output"`
|
||||
Done string `json:"done"`
|
||||
}
|
||||
|
||||
func (w *lunchWorkflow) llmNode(ctx *ag.Context, _ any, _ lunchState) (ag.Command, error) {
|
||||
decisions := w.planner.invoke()
|
||||
sends := make([]ag.Send, 0, 4)
|
||||
for _, d := range decisions {
|
||||
if d.Type == "end" {
|
||||
return ag.Command{
|
||||
Goto: []ag.Send{
|
||||
{Node: w.orderFoodNode, NodeInput: d.Complete},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if d.Type == "sub_agent" {
|
||||
sends = append(sends, ag.Send{Node: w.subAgentNode, NodeInput: d.SubAgent})
|
||||
}
|
||||
if d.Type == "tool" {
|
||||
sends = append(sends, ag.Send{Node: w.toolNode, NodeInput: d.Tool})
|
||||
}
|
||||
}
|
||||
sends = append(sends, ag.Send{Node: w.waitNode})
|
||||
return ag.Command{Goto: sends}, nil
|
||||
}
|
||||
|
||||
func (w *lunchWorkflow) waitNode(ctx *ag.Context, _ any, state lunchState) (ag.Command, error) {
|
||||
event, err := ctx.WaitFor(
|
||||
ag.AnyOf(
|
||||
ag.ChannelCondition{Channel: "tool_completion_channel"},
|
||||
ag.ChannelCondition{Channel: "subagent_completion_channel"},
|
||||
ag.ChannelCondition{Channel: "user_input_channel"},
|
||||
ag.TimerCondition{Seconds: 1},
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
|
||||
output := append([]string(nil), state.Output...)
|
||||
hadChannel := false
|
||||
for _, cond := range event.Conditions {
|
||||
if !cond.Met || cond.ChannelName == "" {
|
||||
continue
|
||||
}
|
||||
for _, raw := range cond.Values {
|
||||
payload, _ := raw.(string)
|
||||
switch cond.ChannelName {
|
||||
case "tool_completion_channel":
|
||||
output = append(output, "tool: "+payload)
|
||||
case "subagent_completion_channel":
|
||||
output = append(output, "sub_agent: "+payload)
|
||||
case "user_input_channel":
|
||||
output = append(output, "user_input: "+payload)
|
||||
}
|
||||
}
|
||||
hadChannel = true
|
||||
}
|
||||
if hadChannel {
|
||||
state.Output = output
|
||||
return ag.Command{Goto: []ag.Send{{Node: w.llmNode}}, Update: state}, nil
|
||||
}
|
||||
|
||||
output = append(output, "timer: no updates yet")
|
||||
state.Output = output
|
||||
return ag.Command{Goto: []ag.Send{{Node: w.waitNode}}, Update: state}, nil
|
||||
}
|
||||
|
||||
func (w *lunchWorkflow) toolNode(ctx *ag.Context, input any, _ lunchState) (ag.Command, error) {
|
||||
toolInput, _ := input.(string)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
err := ctx.PublishToChannel("tool_completion_channel", "tool completed for: "+toolInput)
|
||||
return ag.Command{}, err
|
||||
}
|
||||
|
||||
func (w *lunchWorkflow) subAgentNode(ctx *ag.Context, input any, _ lunchState) (ag.Command, error) {
|
||||
subInput, _ := input.(string)
|
||||
time.Sleep(5 * time.Second)
|
||||
err := ctx.PublishToChannel(
|
||||
"subagent_completion_channel",
|
||||
"research sub agent completed for: "+subInput,
|
||||
)
|
||||
return ag.Command{}, err
|
||||
}
|
||||
|
||||
func (w *lunchWorkflow) orderFoodNode(ctx *ag.Context, input any, state lunchState) (ag.Command, error) {
|
||||
complete, _ := input.(string)
|
||||
output := append([]string(nil), state.Output...)
|
||||
output = append(output, "order_food: "+complete)
|
||||
state.Output = output
|
||||
state.Done = complete
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestSubAgentsEquivalentFlow(t *testing.T) {
|
||||
planner := &mockLLM{
|
||||
responses: [][]decision{
|
||||
{
|
||||
{Type: "sub_agent", SubAgent: "research lunch options"},
|
||||
{Type: "tool", Tool: "slack_tool"},
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{{Type: "sub_agent", SubAgent: "find vegetarian fallback"}},
|
||||
{{Type: "end", Complete: "order submitted"}},
|
||||
},
|
||||
}
|
||||
workflow := &lunchWorkflow{
|
||||
planner: planner,
|
||||
}
|
||||
|
||||
graph := ag.NewAdvancedStateGraph[lunchState]()
|
||||
graph.AddAsyncChannel("tool_completion_channel")
|
||||
graph.AddAsyncChannel("subagent_completion_channel")
|
||||
graph.AddAsyncChannel("user_input_channel")
|
||||
|
||||
graph.AddEntryNode(workflow.llmNode)
|
||||
graph.AddNode(workflow.waitNode)
|
||||
graph.AddNode(workflow.toolNode)
|
||||
graph.AddNode(workflow.subAgentNode)
|
||||
graph.AddFinishNode(workflow.orderFoodNode)
|
||||
|
||||
handler, err := graph.Compile().Start(
|
||||
nil,
|
||||
lunchState{
|
||||
Input: "help me get something for lunch",
|
||||
Output: []string{},
|
||||
Done: "",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if err := handler.PublishToChannel("user_input_channel", "No spicy food please"); err != nil {
|
||||
t.Fatalf("publish failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
|
||||
output := result.Output
|
||||
if len(output) == 0 {
|
||||
t.Fatalf("output is empty, full result=%#v", result)
|
||||
}
|
||||
if result.Done != "order submitted" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
if !slices.Contains(output, "user_input: No spicy food please") {
|
||||
t.Fatalf("missing user input output: %#v", output)
|
||||
}
|
||||
if !slices.Contains(output, "tool: tool completed for: slack_tool") {
|
||||
t.Fatalf("missing tool output: %#v", output)
|
||||
}
|
||||
if !slices.Contains(output, "sub_agent: research sub agent completed for: research lunch options") {
|
||||
t.Fatalf("missing first sub-agent output: %#v", output)
|
||||
}
|
||||
if !slices.Contains(output, "sub_agent: research sub agent completed for: find vegetarian fallback") {
|
||||
t.Fatalf("missing second sub-agent output: %#v", output)
|
||||
}
|
||||
timerCount := 0
|
||||
for _, line := range output {
|
||||
if line == "timer: no updates yet" {
|
||||
timerCount++
|
||||
}
|
||||
}
|
||||
if timerCount < 3 {
|
||||
t.Fatalf("expected >=3 timer outputs, got %d, output=%#v", timerCount, output)
|
||||
}
|
||||
if output[len(output)-1] != "order_food: order submitted" {
|
||||
t.Fatalf("unexpected last output: %#v", output[len(output)-1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ag "github.com/langchain-ai/langgraph/langgraph-go/advancedgraph"
|
||||
)
|
||||
|
||||
type primitiveWorkflow struct {
|
||||
dbWriteCount int
|
||||
intervalMu sync.Mutex
|
||||
intervals map[string][2]time.Time
|
||||
}
|
||||
|
||||
type primitiveState struct {
|
||||
Count int `json:"count"`
|
||||
Logs []string `json:"logs"`
|
||||
Done string `json:"done"`
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startNode(ctx *ag.Context, input int, state primitiveState) (ag.Command, error) {
|
||||
state.Logs = append(state.Logs, fmt.Sprintf("start:%d", input))
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.middleNode, NodeInput: "from_start"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) middleNode(ctx *ag.Context, input string, state primitiveState) (ag.Command, error) {
|
||||
state.Logs = append(state.Logs, "middle:"+input)
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.finishNode, NodeInput: "from_middle"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) finishNode(ctx *ag.Context, input string, state primitiveState) (ag.Command, error) {
|
||||
state.Logs = append(state.Logs, "finish:"+input)
|
||||
state.Done = input
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestInputAndStatePrimitivesCompatible(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
|
||||
graph.AddEntryNode(workflow.startNode)
|
||||
graph.AddNode(workflow.middleNode)
|
||||
graph.AddFinishNode(workflow.finishNode)
|
||||
|
||||
handler, err := graph.Compile().Start(100, primitiveState{
|
||||
Count: 1,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if result.Done != "from_middle" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
if result.Count != 1 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 3 || result.Logs[0] != "start:100" || result.Logs[1] != "middle:from_start" || result.Logs[2] != "finish:from_middle" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startNoFinishNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
state.Logs = append(state.Logs, "start")
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.middleNoFinishNode, NodeInput: "from_start"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) middleNoFinishNode(ctx *ag.Context, input string, state primitiveState) (ag.Command, error) {
|
||||
state.Logs = append(state.Logs, "middle:"+input)
|
||||
state.Count += 1
|
||||
state.Done = "stopped"
|
||||
// No goto and no finish node configured: run should end automatically.
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestRunEndsWithoutFinishNode(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
|
||||
graph.AddEntryNode(workflow.startNoFinishNode)
|
||||
graph.AddNode(workflow.middleNoFinishNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 7,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if result.Done != "stopped" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
if result.Count != 8 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 2 || result.Logs[0] != "start" || result.Logs[1] != "middle:from_start" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startWaitBatchNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if err := ctx.PublishToChannel("events", "a"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if err := ctx.PublishToChannel("events", "b"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if err := ctx.PublishToChannel("events", "c"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.waitBatchNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitBatchNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
result, err := ctx.WaitFor(ag.AnyOf(ag.ChannelCondition{
|
||||
Channel: "events",
|
||||
Min: 2,
|
||||
Max: 4,
|
||||
}))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(result.Conditions) != 1 || !result.Conditions[0].Met {
|
||||
return ag.Command{}, fmt.Errorf("expected one met condition")
|
||||
}
|
||||
values := make([]string, 0, len(result.Conditions[0].Values))
|
||||
for _, v := range result.Conditions[0].Values {
|
||||
s, _ := v.(string)
|
||||
values = append(values, s)
|
||||
}
|
||||
state.Count = len(values)
|
||||
state.Logs = values
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestChannelWaitRespectsMaxM(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("events")
|
||||
graph.AddEntryNode(workflow.startWaitBatchNode)
|
||||
graph.AddFinishNode(workflow.waitBatchNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if result.Count != 3 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 3 || result.Logs[0] != "a" || result.Logs[1] != "b" || result.Logs[2] != "c" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startAnyOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if err := ctx.PublishToChannel("alpha", "a1"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if err := ctx.PublishToChannel("beta", "b1"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.waitAnyOfTwoChannelsNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitAnyOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
first, err := ctx.WaitFor(ag.AnyOf(
|
||||
ag.ChannelCondition{Channel: "alpha"},
|
||||
ag.ChannelCondition{Channel: "beta"},
|
||||
))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(first.Conditions) != 2 {
|
||||
return ag.Command{}, fmt.Errorf("expected 2 condition results, got %d", len(first.Conditions))
|
||||
}
|
||||
if !first.Conditions[0].Met || first.Conditions[0].ChannelName != "alpha" || len(first.Conditions[0].Values) != 1 || first.Conditions[0].Values[0] != "a1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected first condition result: %#v", first.Conditions[0])
|
||||
}
|
||||
if !first.Conditions[1].Met || first.Conditions[1].ChannelName != "beta" || len(first.Conditions[1].Values) != 1 || first.Conditions[1].Values[0] != "b1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected second condition result: %#v", first.Conditions[1])
|
||||
}
|
||||
if err := ctx.PublishToChannel("beta", "b2"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
state.Count = 1
|
||||
state.Logs = []string{
|
||||
"matched=2",
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.verifyBetaAfterAnyOfNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) verifyBetaAfterAnyOfNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
second, err := ctx.WaitFor(ag.AnyOf(
|
||||
ag.ChannelCondition{Channel: "beta"},
|
||||
))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(second.Conditions) != 1 || !second.Conditions[0].Met || second.Conditions[0].ChannelName != "beta" || len(second.Conditions[0].Values) != 1 {
|
||||
return ag.Command{}, fmt.Errorf("unexpected beta condition result: %#v", second.Conditions)
|
||||
}
|
||||
payload, _ := second.Conditions[0].Values[0].(string)
|
||||
state.Count = 2
|
||||
state.Logs = append(state.Logs, fmt.Sprintf("beta=%s", payload))
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestAnyOfConsumesAllReadyChannels(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("alpha")
|
||||
graph.AddAsyncChannel("beta")
|
||||
graph.AddEntryNode(workflow.startAnyOfTwoChannelsNode)
|
||||
graph.AddNode(workflow.waitAnyOfTwoChannelsNode)
|
||||
graph.AddFinishNode(workflow.verifyBetaAfterAnyOfNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if result.Count != 2 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 2 || result.Logs[0] != "matched=2" || result.Logs[1] != "beta=b2" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startAllOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if err := ctx.PublishToChannel("alpha", "a1"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.waitAllOfTwoChannelsNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitAllOfTwoChannelsNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
result, err := ctx.WaitFor(ag.AllOf(
|
||||
ag.ChannelCondition{Channel: "alpha"},
|
||||
ag.ChannelCondition{Channel: "beta"},
|
||||
))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(result.Conditions) != 2 {
|
||||
return ag.Command{}, fmt.Errorf("expected 2 condition results, got %d", len(result.Conditions))
|
||||
}
|
||||
if !result.Conditions[0].Met || result.Conditions[0].ChannelName != "alpha" || len(result.Conditions[0].Values) != 1 || result.Conditions[0].Values[0] != "a1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected alpha condition result: %#v", result.Conditions[0])
|
||||
}
|
||||
if !result.Conditions[1].Met || result.Conditions[1].ChannelName != "beta" || len(result.Conditions[1].Values) != 1 || result.Conditions[1].Values[0] != "b1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected beta condition result: %#v", result.Conditions[1])
|
||||
}
|
||||
state.Count = 2
|
||||
state.Logs = []string{"all_of_channels_ok"}
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestAllOfWaitsUntilAllChannelsAreReady(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("alpha")
|
||||
graph.AddAsyncChannel("beta")
|
||||
graph.AddEntryNode(workflow.startAllOfTwoChannelsNode)
|
||||
graph.AddFinishNode(workflow.waitAllOfTwoChannelsNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
if err := handler.PublishToChannel("beta", "b1"); err != nil {
|
||||
t.Fatalf("publish beta failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if result.Count != 2 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 1 || result.Logs[0] != "all_of_channels_ok" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startAllOfChannelTimerNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if err := ctx.PublishToChannel("alpha", "a1"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.waitAllOfChannelTimerNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitAllOfChannelTimerNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
result, err := ctx.WaitFor(ag.AllOf(
|
||||
ag.ChannelCondition{Channel: "alpha"},
|
||||
ag.TimerCondition{Seconds: 0.05},
|
||||
))
|
||||
if err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
if len(result.Conditions) != 2 {
|
||||
return ag.Command{}, fmt.Errorf("expected 2 condition results, got %d", len(result.Conditions))
|
||||
}
|
||||
if !result.Conditions[0].Met || result.Conditions[0].ChannelName != "alpha" || len(result.Conditions[0].Values) != 1 || result.Conditions[0].Values[0] != "a1" {
|
||||
return ag.Command{}, fmt.Errorf("unexpected channel condition result: %#v", result.Conditions[0])
|
||||
}
|
||||
if !result.Conditions[1].Met {
|
||||
return ag.Command{}, fmt.Errorf("timer condition should be met: %#v", result.Conditions[1])
|
||||
}
|
||||
state.Count = 1
|
||||
state.Logs = []string{"all_of_channel_timer_ok"}
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestAllOfChannelAndTimerMarksBothConditions(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("alpha")
|
||||
graph.AddEntryNode(workflow.startAllOfChannelTimerNode)
|
||||
graph.AddFinishNode(workflow.waitAllOfChannelTimerNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if result.Count != 1 {
|
||||
t.Fatalf("unexpected count: %v", result.Count)
|
||||
}
|
||||
if len(result.Logs) != 1 || result.Logs[0] != "all_of_channel_timer_ok" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startResumeFlagNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
state.Logs = []string{}
|
||||
state.Count = 0
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.resumeFlagNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) resumeFlagNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if !ctx.IsResume() {
|
||||
// Simulate one-time side effect (e.g. DB write).
|
||||
w.dbWriteCount += 1
|
||||
}
|
||||
if _, err := ctx.WaitFor(ag.AnyOf(ag.TimerCondition{Seconds: 0.02})); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
state.Logs = append(state.Logs, fmt.Sprintf("resume=%v", ctx.IsResume()))
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.finishResumeFlagNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) finishResumeFlagNode(_ *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
state.Done = "ok"
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestIsResumeAvoidsDuplicateSideEffects(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddEntryNode(workflow.startResumeFlagNode)
|
||||
graph.AddNode(workflow.resumeFlagNode)
|
||||
graph.AddFinishNode(workflow.finishResumeFlagNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if workflow.dbWriteCount != 1 {
|
||||
t.Fatalf("db write should happen once, got=%v", workflow.dbWriteCount)
|
||||
}
|
||||
if len(result.Logs) != 1 || result.Logs[0] != "resume=true" {
|
||||
t.Fatalf("unexpected logs: %#v", result.Logs)
|
||||
}
|
||||
if result.Done != "ok" {
|
||||
t.Fatalf("unexpected done: %v", result.Done)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) startLockedWorkersNode(_ *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
w.intervalMu.Lock()
|
||||
w.intervals = map[string][2]time.Time{}
|
||||
w.intervalMu.Unlock()
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.lockedWorkerANode, NodeInput: nil},
|
||||
{Node: w.lockedWorkerBNode, NodeInput: nil},
|
||||
{Node: w.waitLockedWorkersNode, NodeInput: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) lockedWorkerANode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
start := time.Now()
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
end := time.Now()
|
||||
w.intervalMu.Lock()
|
||||
w.intervals["a"] = [2]time.Time{start, end}
|
||||
w.intervalMu.Unlock()
|
||||
if err := ctx.PublishToChannel("done", "a"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) lockedWorkerBNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
start := time.Now()
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
end := time.Now()
|
||||
w.intervalMu.Lock()
|
||||
w.intervals["b"] = [2]time.Time{start, end}
|
||||
w.intervalMu.Unlock()
|
||||
if err := ctx.PublishToChannel("done", "b"); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func (w *primitiveWorkflow) waitLockedWorkersNode(ctx *ag.Context, _ any, state primitiveState) (ag.Command, error) {
|
||||
if _, err := ctx.WaitFor(ag.AnyOf(ag.ChannelCondition{Channel: "done", Min: 2})); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{{Node: w.finishResumeFlagNode, NodeInput: nil}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestStateFieldLockingSerializesConflictingNodes(t *testing.T) {
|
||||
workflow := &primitiveWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[primitiveState]()
|
||||
graph.AddAsyncChannel("done")
|
||||
graph.AddEntryNode(workflow.startLockedWorkersNode)
|
||||
graph.AddNode(
|
||||
workflow.lockedWorkerANode,
|
||||
ag.NodeStateOption{LockedFields: []string{"counter"}},
|
||||
)
|
||||
graph.AddNode(
|
||||
workflow.lockedWorkerBNode,
|
||||
ag.NodeStateOption{LockedFields: []string{"counter"}},
|
||||
)
|
||||
graph.AddNode(workflow.waitLockedWorkersNode)
|
||||
graph.AddFinishNode(workflow.finishResumeFlagNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, primitiveState{
|
||||
Count: 0,
|
||||
Logs: []string{},
|
||||
Done: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
if _, err := handler.WaitForResult(); err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
|
||||
workflow.intervalMu.Lock()
|
||||
ia, okA := workflow.intervals["a"]
|
||||
ib, okB := workflow.intervals["b"]
|
||||
workflow.intervalMu.Unlock()
|
||||
if !okA || !okB {
|
||||
t.Fatalf("missing worker intervals: %#v", workflow.intervals)
|
||||
}
|
||||
serialized := !ia[1].After(ib[0]) || !ib[1].After(ia[0])
|
||||
if !serialized {
|
||||
t.Fatalf("expected serialized execution, got overlap: a=%v..%v b=%v..%v", ia[0], ia[1], ib[0], ib[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ag "github.com/langchain-ai/langgraph/langgraph-go/advancedgraph"
|
||||
)
|
||||
|
||||
func TestNewAdvancedStateGraphRejectsNonStructState(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("expected panic for non-struct StateT")
|
||||
}
|
||||
}()
|
||||
_ = ag.NewAdvancedStateGraph[map[string]any]()
|
||||
}
|
||||
|
||||
type stateTypeA struct {
|
||||
X int `json:"x"`
|
||||
}
|
||||
|
||||
type stateTypeB struct {
|
||||
X int `json:"x"`
|
||||
}
|
||||
|
||||
type wrongUpdateWorkflow struct{}
|
||||
|
||||
func (w *wrongUpdateWorkflow) startNode(ctx *ag.Context, _ any, _ stateTypeA) (ag.Command, error) {
|
||||
return ag.Command{Goto: []ag.Send{{Node: w.badNode}}}, nil
|
||||
}
|
||||
|
||||
func (w *wrongUpdateWorkflow) badNode(ctx *ag.Context, _ any, _ stateTypeA) (ag.Command, error) {
|
||||
return ag.Command{Update: stateTypeB{X: 1}}, nil
|
||||
}
|
||||
|
||||
func TestNodeUpdateTypeMustMatchGraphStateType(t *testing.T) {
|
||||
workflow := &wrongUpdateWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[stateTypeA]()
|
||||
graph.AddEntryNode(workflow.startNode)
|
||||
graph.AddFinishNode(workflow.badNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, stateTypeA{X: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
_, err = handler.WaitForResult()
|
||||
if err == nil {
|
||||
t.Fatalf("expected runtime error for wrong update type")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "update type mismatch") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ag "github.com/langchain-ai/langgraph/langgraph-go/advancedgraph"
|
||||
)
|
||||
|
||||
type streamState struct {
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
type streamWorkflow struct{}
|
||||
|
||||
func (w *streamWorkflow) startNode(ctx *ag.Context, _ any, state streamState) (ag.Command, error) {
|
||||
if err := ctx.SendCustomStreamEvent("high", map[string]any{"step": "start", "value": 1}); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if err := ctx.SendCustomStreamEvent("regular", map[string]any{"step": "start", "value": 2}); err != nil {
|
||||
return ag.Command{}, err
|
||||
}
|
||||
return ag.Command{
|
||||
Update: state,
|
||||
Goto: []ag.Send{
|
||||
{Node: w.finishNode},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *streamWorkflow) finishNode(ctx *ag.Context, _ any, state streamState) (ag.Command, error) {
|
||||
state.Done = true
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func TestCustomStreamReceiveAndClose(t *testing.T) {
|
||||
workflow := &streamWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[streamState]()
|
||||
graph.AddCustomOutputStream("high")
|
||||
graph.AddCustomOutputStream("regular")
|
||||
graph.AddEntryNode(workflow.startNode)
|
||||
graph.AddFinishNode(workflow.finishNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, streamState{Done: false}, "custom")
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
|
||||
event, err := handler.ReceiveStream("high")
|
||||
if err != nil {
|
||||
t.Fatalf("receive stream failed: %v", err)
|
||||
}
|
||||
if event == nil {
|
||||
t.Fatalf("expected first stream event, got nil")
|
||||
}
|
||||
eventMap, ok := event.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected event type: %T", event)
|
||||
}
|
||||
if eventMap["step"] != "start" {
|
||||
t.Fatalf("unexpected stream event payload: %#v", eventMap)
|
||||
}
|
||||
|
||||
eventRegular, err := handler.ReceiveStream("regular")
|
||||
if err != nil {
|
||||
t.Fatalf("receive regular stream failed: %v", err)
|
||||
}
|
||||
eventRegularMap, ok := eventRegular.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected regular event type: %T", eventRegular)
|
||||
}
|
||||
if eventRegularMap["value"] != float64(2) {
|
||||
t.Fatalf("unexpected regular stream payload: %#v", eventRegularMap)
|
||||
}
|
||||
|
||||
if err := handler.CloseAllStreams(); err != nil {
|
||||
t.Fatalf("close all streams failed: %v", err)
|
||||
}
|
||||
|
||||
closedEvent, err := handler.ReceiveStream("high")
|
||||
if err != nil {
|
||||
t.Fatalf("receive stream after close failed: %v", err)
|
||||
}
|
||||
if closedEvent != nil {
|
||||
t.Fatalf("expected nil stream event after close, got %#v", closedEvent)
|
||||
}
|
||||
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
if !result.Done {
|
||||
t.Fatalf("expected final state done=true, got %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCustomStreamModeSupported(t *testing.T) {
|
||||
workflow := &streamWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[streamState]()
|
||||
graph.AddCustomOutputStream("regular")
|
||||
graph.AddEntryNode(workflow.startNode)
|
||||
graph.AddFinishNode(workflow.finishNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, streamState{Done: false}, "values")
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
_, runErr := handler.WaitForResult()
|
||||
if runErr == nil {
|
||||
t.Fatalf("expected run error for unsupported stream mode")
|
||||
}
|
||||
if !strings.Contains(runErr.Error(), "only `custom` is supported") {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ag "github.com/langchain-ai/langgraph/langgraph-go/advancedgraph"
|
||||
)
|
||||
|
||||
type updateElisionState struct {
|
||||
X int `json:"x"`
|
||||
S updateStruct `json:"s"`
|
||||
M map[string]int `json:"m"`
|
||||
L []int `json:"l"`
|
||||
PS *updateStruct `json:"ps"`
|
||||
PM *map[string]int `json:"pm"`
|
||||
PL *[]int `json:"pl"`
|
||||
}
|
||||
|
||||
type updateStruct struct {
|
||||
V int `json:"v"`
|
||||
}
|
||||
|
||||
type updateElisionWorkflow struct{}
|
||||
|
||||
func makeState(v int) updateElisionState {
|
||||
m := map[string]int{"n": v}
|
||||
l := []int{v}
|
||||
return updateElisionState{
|
||||
X: v,
|
||||
S: updateStruct{V: v},
|
||||
M: map[string]int{"n": v},
|
||||
L: []int{v},
|
||||
PS: &updateStruct{V: v},
|
||||
PM: &m,
|
||||
PL: &l,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *updateElisionWorkflow) startNoopNode(ctx *ag.Context, _ any, _ updateElisionState) (ag.Command, error) {
|
||||
return ag.Command{
|
||||
Goto: []ag.Send{
|
||||
{Node: w.fastNode},
|
||||
{Node: w.slowNoopNode},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *updateElisionWorkflow) startChangedNode(ctx *ag.Context, _ any, _ updateElisionState) (ag.Command, error) {
|
||||
return ag.Command{
|
||||
Goto: []ag.Send{
|
||||
{Node: w.fastNode},
|
||||
{Node: w.slowChangedNode},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *updateElisionWorkflow) fastNode(ctx *ag.Context, _ any, state updateElisionState) (ag.Command, error) {
|
||||
_ = state
|
||||
return ag.Command{Update: makeState(1)}, nil
|
||||
}
|
||||
|
||||
func (w *updateElisionWorkflow) slowNoopNode(ctx *ag.Context, _ any, state updateElisionState) (ag.Command, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Returns same state as initial snapshot; without runtime elision this can overwrite newer updates.
|
||||
return ag.Command{Update: state}, nil
|
||||
}
|
||||
|
||||
func (w *updateElisionWorkflow) slowChangedNode(ctx *ag.Context, _ any, _ updateElisionState) (ag.Command, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Real change should not be elided.
|
||||
return ag.Command{Update: makeState(2)}, nil
|
||||
}
|
||||
|
||||
func assertStateEquals(t *testing.T, got updateElisionState, expected updateElisionState) {
|
||||
t.Helper()
|
||||
if got.X != expected.X {
|
||||
t.Fatalf("unexpected X: got=%d want=%d", got.X, expected.X)
|
||||
}
|
||||
if got.S != expected.S {
|
||||
t.Fatalf("unexpected S: got=%#v want=%#v", got.S, expected.S)
|
||||
}
|
||||
if !reflect.DeepEqual(got.M, expected.M) {
|
||||
t.Fatalf("unexpected M: got=%#v want=%#v", got.M, expected.M)
|
||||
}
|
||||
if !reflect.DeepEqual(got.L, expected.L) {
|
||||
t.Fatalf("unexpected L: got=%#v want=%#v", got.L, expected.L)
|
||||
}
|
||||
if got.PS == nil || expected.PS == nil || *got.PS != *expected.PS {
|
||||
t.Fatalf("unexpected PS: got=%#v want=%#v", got.PS, expected.PS)
|
||||
}
|
||||
if got.PM == nil || expected.PM == nil || !reflect.DeepEqual(*got.PM, *expected.PM) {
|
||||
t.Fatalf("unexpected PM: got=%#v want=%#v", got.PM, expected.PM)
|
||||
}
|
||||
if got.PL == nil || expected.PL == nil || !reflect.DeepEqual(*got.PL, *expected.PL) {
|
||||
t.Fatalf("unexpected PL: got=%#v want=%#v", got.PL, expected.PL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopSlowUpdateCanOverrideFastUpdate(t *testing.T) {
|
||||
workflow := &updateElisionWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[updateElisionState]()
|
||||
graph.AddEntryNode(workflow.startNoopNode)
|
||||
graph.AddNode(workflow.fastNode)
|
||||
graph.AddFinishNode(workflow.slowNoopNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, makeState(0))
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
assertStateEquals(t, result, makeState(0))
|
||||
}
|
||||
|
||||
func TestChangedSlowUpdateOverridesFastUpdate(t *testing.T) {
|
||||
workflow := &updateElisionWorkflow{}
|
||||
graph := ag.NewAdvancedStateGraph[updateElisionState]()
|
||||
graph.AddEntryNode(workflow.startChangedNode)
|
||||
graph.AddNode(workflow.fastNode)
|
||||
graph.AddFinishNode(workflow.slowChangedNode)
|
||||
|
||||
handler, err := graph.Compile().Start(nil, makeState(0))
|
||||
if err != nil {
|
||||
t.Fatalf("start failed: %v", err)
|
||||
}
|
||||
result, err := handler.WaitForResult()
|
||||
if err != nil {
|
||||
t.Fatalf("result failed: %v", err)
|
||||
}
|
||||
assertStateEquals(t, result, makeState(2))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.PHONY: format lint test
|
||||
|
||||
format:
|
||||
uv run ruff format .
|
||||
uv run ruff check --fix .
|
||||
|
||||
lint:
|
||||
uv run ruff check .
|
||||
uv run ty check
|
||||
|
||||
test:
|
||||
uv run pytest $(TEST)
|
||||
@@ -0,0 +1,111 @@
|
||||
# langgraph-checkpoint-conformance
|
||||
|
||||
Conformance test suite for [LangGraph](https://github.com/langchain-ai/langgraph) checkpointer implementations.
|
||||
|
||||
Validates that a `BaseCheckpointSaver` subclass correctly implements the checkpoint storage contract — blob round-trips, metadata preservation, namespace isolation, incremental channel updates, and more.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install langgraph-checkpoint-conformance
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Register your checkpointer with `@checkpointer_test` and run `validate()`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from langgraph.checkpoint.conformance import checkpointer_test, validate
|
||||
|
||||
@checkpointer_test(name="MyCheckpointer")
|
||||
async def my_checkpointer():
|
||||
saver = MyCheckpointer(...)
|
||||
yield saver
|
||||
# cleanup runs after yield
|
||||
|
||||
async def main():
|
||||
report = await validate(my_checkpointer)
|
||||
report.print_report()
|
||||
assert report.passed_all_base()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Or in a pytest test:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from langgraph.checkpoint.conformance import checkpointer_test, validate
|
||||
|
||||
@checkpointer_test(name="MyCheckpointer")
|
||||
async def my_checkpointer():
|
||||
yield MyCheckpointer(...)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conformance():
|
||||
report = await validate(my_checkpointer)
|
||||
report.print_report()
|
||||
assert report.passed_all_base()
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
The suite tests **base** capabilities (required) and **extended** capabilities (optional, auto-detected):
|
||||
|
||||
| Capability | Required | Method |
|
||||
|---|---|---|
|
||||
| `put` | yes | `aput` |
|
||||
| `put_writes` | yes | `aput_writes` |
|
||||
| `get_tuple` | yes | `aget_tuple` |
|
||||
| `list` | yes | `alist` |
|
||||
| `delete_thread` | yes | `adelete_thread` |
|
||||
| `delete_for_runs` | no | `adelete_for_runs` |
|
||||
| `copy_thread` | no | `acopy_thread` |
|
||||
| `prune` | no | `aprune` |
|
||||
|
||||
Extended capabilities are detected by checking whether the method is overridden from `BaseCheckpointSaver`. If not overridden, those tests are skipped.
|
||||
|
||||
## Options
|
||||
|
||||
### Progress output
|
||||
|
||||
```python
|
||||
from langgraph.checkpoint.conformance.report import ProgressCallbacks
|
||||
|
||||
# Dot-style progress (. per pass, F per fail)
|
||||
report = await validate(my_checkpointer, progress=ProgressCallbacks.default())
|
||||
|
||||
# Verbose (per-test names + stacktraces on failure)
|
||||
report = await validate(my_checkpointer, progress=ProgressCallbacks.verbose())
|
||||
```
|
||||
|
||||
### Skip capabilities
|
||||
|
||||
```python
|
||||
@checkpointer_test(name="MyCheckpointer", skip_capabilities={"prune"})
|
||||
async def my_checkpointer():
|
||||
yield MyCheckpointer(...)
|
||||
```
|
||||
|
||||
### Run specific capabilities
|
||||
|
||||
```python
|
||||
report = await validate(my_checkpointer, capabilities={"put", "list"})
|
||||
```
|
||||
|
||||
### Lifespan (one-time setup/teardown)
|
||||
|
||||
For expensive setup like database creation:
|
||||
|
||||
```python
|
||||
async def db_lifespan():
|
||||
await create_database()
|
||||
yield
|
||||
await drop_database()
|
||||
|
||||
@checkpointer_test(name="PostgresSaver", lifespan=db_lifespan)
|
||||
async def pg_checkpointer():
|
||||
async with PostgresSaver.from_conn_string(CONN_STRING) as saver:
|
||||
yield saver
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
"""langgraph-checkpoint-conformance: conformance test suite for checkpointer implementations."""
|
||||
|
||||
from langgraph.checkpoint.conformance.initializer import checkpointer_test
|
||||
from langgraph.checkpoint.conformance.validate import validate
|
||||
|
||||
__all__ = [
|
||||
"checkpointer_test",
|
||||
"validate",
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Capability detection for checkpointer implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class Capability(str, Enum):
|
||||
"""Capabilities that a checkpointer may support."""
|
||||
|
||||
PUT = "put"
|
||||
PUT_WRITES = "put_writes"
|
||||
GET_TUPLE = "get_tuple"
|
||||
LIST = "list"
|
||||
DELETE_THREAD = "delete_thread"
|
||||
DELETE_FOR_RUNS = "delete_for_runs"
|
||||
COPY_THREAD = "copy_thread"
|
||||
PRUNE = "prune"
|
||||
|
||||
|
||||
# Capabilities that every checkpointer must support.
|
||||
BASE_CAPABILITIES = frozenset(
|
||||
{
|
||||
Capability.PUT,
|
||||
Capability.PUT_WRITES,
|
||||
Capability.GET_TUPLE,
|
||||
Capability.LIST,
|
||||
Capability.DELETE_THREAD,
|
||||
}
|
||||
)
|
||||
|
||||
# Capabilities that are optional extensions.
|
||||
EXTENDED_CAPABILITIES = frozenset(
|
||||
{
|
||||
Capability.DELETE_FOR_RUNS,
|
||||
Capability.COPY_THREAD,
|
||||
Capability.PRUNE,
|
||||
}
|
||||
)
|
||||
|
||||
ALL_CAPABILITIES = BASE_CAPABILITIES | EXTENDED_CAPABILITIES
|
||||
|
||||
# Maps capability to the async method name on BaseCheckpointSaver (or subclass).
|
||||
_CAPABILITY_METHOD_MAP: dict[Capability, str] = {
|
||||
Capability.PUT: "aput",
|
||||
Capability.PUT_WRITES: "aput_writes",
|
||||
Capability.GET_TUPLE: "aget_tuple",
|
||||
Capability.LIST: "alist",
|
||||
Capability.DELETE_THREAD: "adelete_thread",
|
||||
Capability.DELETE_FOR_RUNS: "adelete_for_runs",
|
||||
Capability.COPY_THREAD: "acopy_thread",
|
||||
Capability.PRUNE: "aprune",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetectedCapabilities:
|
||||
"""Result of capability detection for a checkpointer type."""
|
||||
|
||||
detected: frozenset[Capability]
|
||||
missing: frozenset[Capability]
|
||||
|
||||
@classmethod
|
||||
def from_instance(cls, saver: BaseCheckpointSaver) -> DetectedCapabilities:
|
||||
"""Detect capabilities from a checkpointer instance."""
|
||||
inner_type = type(saver)
|
||||
detected: set[Capability] = set()
|
||||
|
||||
for cap, method_name in _CAPABILITY_METHOD_MAP.items():
|
||||
if _is_overridden(inner_type, method_name):
|
||||
detected.add(cap)
|
||||
|
||||
detected_fs = frozenset(detected)
|
||||
return cls(
|
||||
detected=detected_fs,
|
||||
missing=ALL_CAPABILITIES - detected_fs,
|
||||
)
|
||||
|
||||
|
||||
def _is_overridden(inner_type: type, method: str) -> bool:
|
||||
"""Check if *method* on *inner_type* differs from the base class default."""
|
||||
base = getattr(BaseCheckpointSaver, method, None)
|
||||
impl = getattr(inner_type, method, None)
|
||||
if base is None or impl is None:
|
||||
return impl is not None
|
||||
return impl is not base
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Checkpointer test registration and factory management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
# Type for the lifespan async context manager factory.
|
||||
LifespanFactory = Callable[[], AsyncGenerator[None, None]]
|
||||
|
||||
# Module-level registry of decorated checkpointer factories.
|
||||
_REGISTRY: dict[str, RegisteredCheckpointer] = {}
|
||||
|
||||
|
||||
async def _noop_lifespan() -> AsyncGenerator[None, None]:
|
||||
yield
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegisteredCheckpointer:
|
||||
"""A registered checkpointer test factory."""
|
||||
|
||||
name: str
|
||||
factory: Callable[[], AsyncGenerator[BaseCheckpointSaver, None]]
|
||||
skip_capabilities: set[str] = field(default_factory=set)
|
||||
lifespan: LifespanFactory = _noop_lifespan
|
||||
|
||||
@asynccontextmanager
|
||||
async def create(self) -> AsyncGenerator[BaseCheckpointSaver, None]:
|
||||
"""Create a fresh checkpointer instance via the async generator."""
|
||||
gen = self.factory()
|
||||
try:
|
||||
saver = await gen.__anext__()
|
||||
yield saver
|
||||
finally:
|
||||
try:
|
||||
await gen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
@asynccontextmanager
|
||||
async def enter_lifespan(self) -> AsyncGenerator[None, None]:
|
||||
"""Enter the lifespan context (once per validation run)."""
|
||||
gen = self.lifespan()
|
||||
try:
|
||||
await gen.__anext__()
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
await gen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
pass
|
||||
|
||||
|
||||
def checkpointer_test(
|
||||
name: str,
|
||||
*,
|
||||
skip_capabilities: set[str] | None = None,
|
||||
lifespan: LifespanFactory | None = None,
|
||||
) -> Callable[[Any], RegisteredCheckpointer]:
|
||||
"""Register an async generator as a checkpointer test factory.
|
||||
|
||||
The factory is called once per capability suite to create a fresh
|
||||
checkpointer. The optional `lifespan` is an async generator that
|
||||
runs once for the entire validation run (e.g. to create/destroy a
|
||||
database).
|
||||
|
||||
Example::
|
||||
|
||||
@checkpointer_test(name="InMemorySaver")
|
||||
async def memory_checkpointer():
|
||||
yield InMemorySaver()
|
||||
|
||||
With lifespan::
|
||||
|
||||
async def pg_lifespan():
|
||||
await create_database()
|
||||
yield
|
||||
await drop_database()
|
||||
|
||||
@checkpointer_test(name="PostgresSaver", lifespan=pg_lifespan)
|
||||
async def pg_checkpointer():
|
||||
yield PostgresSaver(conn_string="...")
|
||||
"""
|
||||
|
||||
def decorator(fn: Any) -> RegisteredCheckpointer:
|
||||
registered = RegisteredCheckpointer(
|
||||
name=name,
|
||||
factory=fn,
|
||||
skip_capabilities=skip_capabilities or set(),
|
||||
lifespan=lifespan or _noop_lifespan,
|
||||
)
|
||||
_REGISTRY[name] = registered
|
||||
return registered
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Capability report: results, progress callbacks, and pretty-printing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from langgraph.checkpoint.conformance.capabilities import (
|
||||
BASE_CAPABILITIES,
|
||||
EXTENDED_CAPABILITIES,
|
||||
Capability,
|
||||
)
|
||||
|
||||
# Callback type for per-test progress reporting.
|
||||
# (capability_name, test_name, passed, error_msg_or_None) -> None
|
||||
OnTestResult = Callable[[str, str, bool, str | None], None]
|
||||
|
||||
# Callback type for capability-level events.
|
||||
# (capability_name, detected) -> None
|
||||
OnCapabilityStart = Callable[[str, bool], None]
|
||||
|
||||
|
||||
class ProgressCallbacks:
|
||||
"""Grouped callbacks for progress reporting during validation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_capability_start: Callable[[str, bool], None] | None = None,
|
||||
on_test_result: OnTestResult | None = None,
|
||||
on_capability_end: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.on_capability_start = on_capability_start
|
||||
self.on_test_result = on_test_result
|
||||
self.on_capability_end = on_capability_end
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> ProgressCallbacks:
|
||||
"""Dot-style progress: ``.`` per pass, ``F`` per fail."""
|
||||
|
||||
def _cap_start(capability: str, detected: bool) -> None:
|
||||
if detected:
|
||||
print(f" {capability}: ", end="", flush=True)
|
||||
else:
|
||||
print(f" ⊘ {capability} (not implemented)")
|
||||
|
||||
def _test_result(
|
||||
capability: str, test_name: str, passed: bool, error: str | None
|
||||
) -> None:
|
||||
print("." if passed else "F", end="", flush=True)
|
||||
|
||||
def _cap_end(capability: str) -> None:
|
||||
print() # newline after dots
|
||||
|
||||
return cls(
|
||||
on_capability_start=_cap_start,
|
||||
on_test_result=_test_result,
|
||||
on_capability_end=_cap_end,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def verbose(cls) -> ProgressCallbacks:
|
||||
"""Per-test output with names and errors."""
|
||||
|
||||
def _cap_start(capability: str, detected: bool) -> None:
|
||||
if detected:
|
||||
print(f" {capability}:")
|
||||
else:
|
||||
print(f" ⊘ {capability} (not implemented)")
|
||||
|
||||
def _test_result(
|
||||
capability: str, test_name: str, passed: bool, error: str | None
|
||||
) -> None:
|
||||
icon = "✓" if passed else "✗"
|
||||
print(f" {icon} {test_name}")
|
||||
if error:
|
||||
for line in error.rstrip().splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
return cls(
|
||||
on_capability_start=_cap_start,
|
||||
on_test_result=_test_result,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def quiet(cls) -> ProgressCallbacks:
|
||||
"""No progress output."""
|
||||
return cls()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapabilityResult:
|
||||
"""Result of running a single capability's test suite."""
|
||||
|
||||
detected: bool = False
|
||||
passed: bool | None = None # None = skipped
|
||||
tests_passed: int = 0
|
||||
tests_failed: int = 0
|
||||
tests_skipped: int = 0
|
||||
failures: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapabilityReport:
|
||||
"""Aggregate report across all capabilities."""
|
||||
|
||||
checkpointer_name: str
|
||||
results: dict[str, CapabilityResult] = field(default_factory=dict)
|
||||
|
||||
def passed_all_base(self) -> bool:
|
||||
"""Whether all base capability tests passed."""
|
||||
for cap in BASE_CAPABILITIES:
|
||||
result = self.results.get(cap.value)
|
||||
if result is None or result.passed is not True:
|
||||
return False
|
||||
return True
|
||||
|
||||
def passed_all(self) -> bool:
|
||||
"""Whether every detected capability's tests passed."""
|
||||
for result in self.results.values():
|
||||
if result.detected and result.passed is not True:
|
||||
return False
|
||||
return True
|
||||
|
||||
def conformance_level(self) -> str:
|
||||
"""Return a human-readable conformance level string."""
|
||||
if self.passed_all():
|
||||
return "FULL"
|
||||
if self.passed_all_base():
|
||||
return "BASE+PARTIAL"
|
||||
return "BASE" if self._any_base_passed() else "NONE"
|
||||
|
||||
def _any_base_passed(self) -> bool:
|
||||
for cap in BASE_CAPABILITIES:
|
||||
result = self.results.get(cap.value)
|
||||
if result and result.passed is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
def print_report(self) -> None:
|
||||
"""Pretty-print the report to stdout."""
|
||||
width = 52
|
||||
border = "=" * width
|
||||
print(f"\n{'':>2}{border}")
|
||||
print(f"{'':>2} Checkpointer Validation: {self.checkpointer_name}")
|
||||
print(f"{'':>2}{border}")
|
||||
|
||||
def _section(title: str, caps: frozenset[Capability]) -> None:
|
||||
print(f"{'':>2} {title}")
|
||||
for cap in sorted(caps, key=lambda c: c.value):
|
||||
result = self.results.get(cap.value)
|
||||
if result is None:
|
||||
icon = " "
|
||||
suffix = "(no tests)"
|
||||
elif not result.detected:
|
||||
icon = "⊘ "
|
||||
suffix = "(not implemented)"
|
||||
elif result.passed is True:
|
||||
icon = "✅"
|
||||
suffix = ""
|
||||
elif result.passed is False:
|
||||
icon = "❌"
|
||||
suffix = f"({result.tests_failed} failed)"
|
||||
else:
|
||||
icon = "⏭ "
|
||||
suffix = "(skipped)"
|
||||
print(f"{'':>2} {icon} {cap.value:20s} {suffix}")
|
||||
print()
|
||||
|
||||
_section("BASE CAPABILITIES", BASE_CAPABILITIES)
|
||||
_section("EXTENDED CAPABILITIES", EXTENDED_CAPABILITIES)
|
||||
|
||||
total = sum(1 for r in self.results.values() if r.detected)
|
||||
passed = sum(
|
||||
1 for r in self.results.values() if r.detected and r.passed is True
|
||||
)
|
||||
level = self.conformance_level()
|
||||
print(f"{'':>2} Result: {level} ({passed}/{total})")
|
||||
print(f"{'':>2}{border}\n")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Return a JSON-serializable dict."""
|
||||
return {
|
||||
"checkpointer_name": self.checkpointer_name,
|
||||
"conformance_level": self.conformance_level(),
|
||||
"results": {
|
||||
name: {
|
||||
"detected": r.detected,
|
||||
"passed": r.passed,
|
||||
"tests_passed": r.tests_passed,
|
||||
"tests_failed": r.tests_failed,
|
||||
"tests_skipped": r.tests_skipped,
|
||||
"failures": r.failures,
|
||||
}
|
||||
for name, r in self.results.items()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Test spec modules for each checkpointer capability."""
|
||||
|
||||
from langgraph.checkpoint.conformance.spec.test_copy_thread import (
|
||||
run_copy_thread_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
|
||||
run_delete_for_runs_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
|
||||
run_delete_thread_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_put import run_put_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_put_writes import run_put_writes_tests
|
||||
|
||||
__all__ = [
|
||||
"run_put_tests",
|
||||
"run_put_writes_tests",
|
||||
"run_get_tuple_tests",
|
||||
"run_list_tests",
|
||||
"run_delete_thread_tests",
|
||||
"run_delete_for_runs_tests",
|
||||
"run_copy_thread_tests",
|
||||
"run_prune_tests",
|
||||
]
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
"""COPY_THREAD capability tests — acopy_thread."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_source_thread(
|
||||
saver: BaseCheckpointSaver,
|
||||
tid: str,
|
||||
*,
|
||||
n: int = 3,
|
||||
namespaces: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Create n checkpoints on tid (optionally across namespaces). Returns stored configs."""
|
||||
nss = namespaces or [""]
|
||||
stored = []
|
||||
for ns in nss:
|
||||
parent_cfg = None
|
||||
for i in range(n):
|
||||
config = generate_config(tid, checkpoint_ns=ns)
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint(channel_values={"step": i})
|
||||
cp["channel_versions"] = {"step": 1}
|
||||
parent_cfg = await saver.aput(
|
||||
config, cp, generate_metadata(step=i), {"step": 1}
|
||||
)
|
||||
stored.append(parent_cfg)
|
||||
return stored
|
||||
|
||||
|
||||
async def test_copy_thread_basic(saver: BaseCheckpointSaver) -> None:
|
||||
"""Checkpoints appear on target thread."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
await _setup_source_thread(saver, src)
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(dst)):
|
||||
results.append(tup)
|
||||
assert len(results) == 3, f"Expected 3 copied checkpoints, got {len(results)}"
|
||||
|
||||
|
||||
async def test_copy_thread_all_checkpoints(saver: BaseCheckpointSaver) -> None:
|
||||
"""All checkpoints copied, not just latest."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
await _setup_source_thread(saver, src, n=3)
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
src_results = []
|
||||
async for tup in saver.alist(generate_config(src)):
|
||||
src_results.append(tup)
|
||||
|
||||
dst_results = []
|
||||
async for tup in saver.alist(generate_config(dst)):
|
||||
dst_results.append(tup)
|
||||
|
||||
assert len(dst_results) == len(src_results)
|
||||
# Verify content matches
|
||||
for s, d in zip(
|
||||
sorted(src_results, key=lambda t: t.checkpoint["id"]),
|
||||
sorted(dst_results, key=lambda t: t.checkpoint["id"]),
|
||||
strict=True,
|
||||
):
|
||||
assert s.checkpoint["channel_values"] == d.checkpoint["channel_values"], (
|
||||
f"channel_values mismatch for checkpoint {s.checkpoint['id']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_copy_thread_preserves_metadata(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Metadata intact on copied checkpoints."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
await _setup_source_thread(saver, src, n=2)
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
src_tuples = []
|
||||
async for tup in saver.alist(generate_config(src)):
|
||||
src_tuples.append(tup)
|
||||
|
||||
dst_tuples = []
|
||||
async for tup in saver.alist(generate_config(dst)):
|
||||
dst_tuples.append(tup)
|
||||
|
||||
for s, d in zip(
|
||||
sorted(src_tuples, key=lambda t: t.metadata.get("step", 0)),
|
||||
sorted(dst_tuples, key=lambda t: t.metadata.get("step", 0)),
|
||||
strict=True,
|
||||
):
|
||||
for key in s.metadata:
|
||||
assert s.metadata.get(key) == d.metadata.get(key), (
|
||||
f"metadata[{key!r}] mismatch: {s.metadata.get(key)!r} != {d.metadata.get(key)!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_copy_thread_preserves_namespaces(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Root + child namespaces copied."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
await _setup_source_thread(saver, src, n=1, namespaces=["", "child:1"])
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
for ns in ["", "child:1"]:
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(dst, checkpoint_ns=ns)):
|
||||
results.append(tup)
|
||||
assert len(results) == 1, (
|
||||
f"Expected 1 checkpoint in namespace '{ns}', got {len(results)}"
|
||||
)
|
||||
|
||||
|
||||
async def test_copy_thread_preserves_writes(saver: BaseCheckpointSaver) -> None:
|
||||
"""Pending writes copied."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
configs = await _setup_source_thread(saver, src, n=1)
|
||||
|
||||
# Add a write to the source
|
||||
await saver.aput_writes(configs[-1], [("ch", "write_val")], str(uuid4()))
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
tup = await saver.aget_tuple(generate_config(dst))
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 1, (
|
||||
f"Expected 1 write, got {len(tup.pending_writes)}"
|
||||
)
|
||||
assert tup.pending_writes[0][1] == "ch", (
|
||||
f"channel mismatch: {tup.pending_writes[0][1]!r}"
|
||||
)
|
||||
assert tup.pending_writes[0][2] == "write_val", (
|
||||
f"value mismatch: {tup.pending_writes[0][2]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_copy_thread_preserves_ordering(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Checkpoint order maintained."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
await _setup_source_thread(saver, src, n=4)
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
src_ids = []
|
||||
async for tup in saver.alist(generate_config(src)):
|
||||
src_ids.append(tup.checkpoint["id"])
|
||||
|
||||
dst_ids = []
|
||||
async for tup in saver.alist(generate_config(dst)):
|
||||
dst_ids.append(tup.checkpoint["id"])
|
||||
|
||||
# Order should match (both newest-first)
|
||||
assert src_ids == dst_ids
|
||||
|
||||
|
||||
async def test_copy_thread_source_unchanged(saver: BaseCheckpointSaver) -> None:
|
||||
"""Source thread still intact after copy."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
await _setup_source_thread(saver, src, n=2)
|
||||
|
||||
# Snapshot source before copy
|
||||
src_before = []
|
||||
async for tup in saver.alist(generate_config(src)):
|
||||
src_before.append(tup.checkpoint["id"])
|
||||
|
||||
await saver.acopy_thread(src, dst)
|
||||
# Source should be unchanged
|
||||
src_after = []
|
||||
async for tup in saver.alist(generate_config(src)):
|
||||
src_after.append(tup.checkpoint["id"])
|
||||
|
||||
assert src_before == src_after
|
||||
|
||||
|
||||
async def test_copy_thread_nonexistent_source(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Graceful handling of non-existent source thread."""
|
||||
src = str(uuid4())
|
||||
dst = str(uuid4())
|
||||
|
||||
# Should not raise (or raise a known error)
|
||||
try:
|
||||
await saver.acopy_thread(src, dst)
|
||||
except Exception:
|
||||
pass # Some implementations may raise; that's acceptable
|
||||
|
||||
# Destination should be empty
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(dst)):
|
||||
results.append(tup)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
ALL_COPY_THREAD_TESTS = [
|
||||
test_copy_thread_basic,
|
||||
test_copy_thread_all_checkpoints,
|
||||
test_copy_thread_preserves_metadata,
|
||||
test_copy_thread_preserves_namespaces,
|
||||
test_copy_thread_preserves_writes,
|
||||
test_copy_thread_preserves_ordering,
|
||||
test_copy_thread_source_unchanged,
|
||||
test_copy_thread_nonexistent_source,
|
||||
]
|
||||
|
||||
|
||||
async def run_copy_thread_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all copy_thread tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_COPY_THREAD_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("copy_thread", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"copy_thread", test_fn.__name__, False, traceback.format_exc()
|
||||
)
|
||||
return passed, failed, failures
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
"""DELETE_FOR_RUNS capability tests — adelete_for_runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def _put_with_run_id(
|
||||
saver: BaseCheckpointSaver,
|
||||
tid: str,
|
||||
run_id: str,
|
||||
*,
|
||||
checkpoint_ns: str = "",
|
||||
parent_config: dict | None = None,
|
||||
) -> dict:
|
||||
"""Put a checkpoint with a run_id in metadata, return stored config."""
|
||||
config = generate_config(tid, checkpoint_ns=checkpoint_ns)
|
||||
if parent_config:
|
||||
config["configurable"]["checkpoint_id"] = parent_config["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata(run_id=run_id)
|
||||
return await saver.aput(config, cp, md, {})
|
||||
|
||||
|
||||
async def test_delete_for_runs_single(saver: BaseCheckpointSaver) -> None:
|
||||
"""One run_id removed."""
|
||||
tid = str(uuid4())
|
||||
run1, run2 = str(uuid4()), str(uuid4())
|
||||
|
||||
stored1 = await _put_with_run_id(saver, tid, run1)
|
||||
await _put_with_run_id(saver, tid, run2, parent_config=stored1)
|
||||
|
||||
# Pre-delete: verify both runs exist
|
||||
pre_results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
pre_results.append(tup)
|
||||
pre_run_ids = {t.metadata.get("run_id") for t in pre_results}
|
||||
assert run1 in pre_run_ids, "Pre-delete: run1 should exist"
|
||||
assert run2 in pre_run_ids, "Pre-delete: run2 should exist"
|
||||
|
||||
await saver.adelete_for_runs([run1])
|
||||
# run1's checkpoint should be gone; run2 should remain
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
|
||||
run_ids = {t.metadata.get("run_id") for t in results}
|
||||
assert run1 not in run_ids
|
||||
assert run2 in run_ids
|
||||
|
||||
|
||||
async def test_delete_for_runs_multiple(saver: BaseCheckpointSaver) -> None:
|
||||
"""List of run_ids removed."""
|
||||
tid = str(uuid4())
|
||||
run1, run2, run3 = str(uuid4()), str(uuid4()), str(uuid4())
|
||||
|
||||
s1 = await _put_with_run_id(saver, tid, run1)
|
||||
s2 = await _put_with_run_id(saver, tid, run2, parent_config=s1)
|
||||
await _put_with_run_id(saver, tid, run3, parent_config=s2)
|
||||
|
||||
# Pre-delete: verify all 3 runs exist
|
||||
pre_results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
pre_results.append(tup)
|
||||
pre_run_ids = {t.metadata.get("run_id") for t in pre_results}
|
||||
assert run1 in pre_run_ids, "Pre-delete: run1 should exist"
|
||||
assert run2 in pre_run_ids, "Pre-delete: run2 should exist"
|
||||
assert run3 in pre_run_ids, "Pre-delete: run3 should exist"
|
||||
|
||||
await saver.adelete_for_runs([run1, run2])
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
|
||||
run_ids = {t.metadata.get("run_id") for t in results}
|
||||
assert run1 not in run_ids
|
||||
assert run2 not in run_ids
|
||||
assert run3 in run_ids
|
||||
|
||||
|
||||
async def test_delete_for_runs_preserves_other_runs(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Unrelated runs untouched."""
|
||||
tid = str(uuid4())
|
||||
run_keep = str(uuid4())
|
||||
run_delete = str(uuid4())
|
||||
|
||||
await _put_with_run_id(saver, tid, run_keep)
|
||||
await _put_with_run_id(saver, tid, run_delete)
|
||||
|
||||
# Pre-delete: verify both runs exist
|
||||
pre_results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
pre_results.append(tup)
|
||||
pre_run_ids = {t.metadata.get("run_id") for t in pre_results}
|
||||
assert run_keep in pre_run_ids, "Pre-delete: run_keep should exist"
|
||||
assert run_delete in pre_run_ids, "Pre-delete: run_delete should exist"
|
||||
|
||||
await saver.adelete_for_runs([run_delete])
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
|
||||
run_ids = {t.metadata.get("run_id") for t in results}
|
||||
assert run_keep in run_ids
|
||||
|
||||
|
||||
async def test_delete_for_runs_removes_writes(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Associated writes cleaned up."""
|
||||
tid = str(uuid4())
|
||||
run1 = str(uuid4())
|
||||
|
||||
stored = await _put_with_run_id(saver, tid, run1)
|
||||
await saver.aput_writes(stored, [("ch", "val")], str(uuid4()))
|
||||
|
||||
# Pre-delete: verify writes exist
|
||||
pre_tup = await saver.aget_tuple(stored)
|
||||
assert pre_tup is not None, "Pre-delete: checkpoint should exist"
|
||||
assert pre_tup.pending_writes is not None and len(pre_tup.pending_writes) == 1, (
|
||||
f"Pre-delete: expected 1 write, got {len(pre_tup.pending_writes) if pre_tup.pending_writes else 0}"
|
||||
)
|
||||
|
||||
await saver.adelete_for_runs([run1])
|
||||
# The checkpoint (and its writes) should be gone
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is None
|
||||
|
||||
|
||||
async def test_delete_for_runs_empty_list_noop(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Empty list no error."""
|
||||
await saver.adelete_for_runs([])
|
||||
|
||||
|
||||
async def test_delete_for_runs_nonexistent_noop(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Missing run_ids no error."""
|
||||
await saver.adelete_for_runs([str(uuid4())])
|
||||
|
||||
|
||||
async def test_delete_for_runs_across_namespaces(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""All namespaces cleaned."""
|
||||
tid = str(uuid4())
|
||||
run1 = str(uuid4())
|
||||
|
||||
await _put_with_run_id(saver, tid, run1, checkpoint_ns="")
|
||||
await _put_with_run_id(saver, tid, run1, checkpoint_ns="child:1")
|
||||
|
||||
# Pre-delete: verify run1 present in both namespaces
|
||||
for ns in ["", "child:1"]:
|
||||
pre_results = []
|
||||
async for tup in saver.alist(generate_config(tid, checkpoint_ns=ns)):
|
||||
pre_results.append(tup)
|
||||
pre_run_ids = {t.metadata.get("run_id") for t in pre_results}
|
||||
assert run1 in pre_run_ids, f"Pre-delete: run1 should exist in ns='{ns}'"
|
||||
|
||||
await saver.adelete_for_runs([run1])
|
||||
for ns in ["", "child:1"]:
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid, checkpoint_ns=ns)):
|
||||
results.append(tup)
|
||||
run_ids = {t.metadata.get("run_id") for t in results}
|
||||
assert run1 not in run_ids
|
||||
|
||||
|
||||
ALL_DELETE_FOR_RUNS_TESTS = [
|
||||
test_delete_for_runs_single,
|
||||
test_delete_for_runs_multiple,
|
||||
test_delete_for_runs_preserves_other_runs,
|
||||
test_delete_for_runs_removes_writes,
|
||||
test_delete_for_runs_empty_list_noop,
|
||||
test_delete_for_runs_nonexistent_noop,
|
||||
test_delete_for_runs_across_namespaces,
|
||||
]
|
||||
|
||||
|
||||
async def run_delete_for_runs_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all delete_for_runs tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_DELETE_FOR_RUNS_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("delete_for_runs", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"delete_for_runs", test_fn.__name__, False, traceback.format_exc()
|
||||
)
|
||||
return passed, failed, failures
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
"""DELETE_THREAD capability tests — adelete_thread."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def test_delete_thread_removes_checkpoints(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""All checkpoints gone after delete."""
|
||||
tid = str(uuid4())
|
||||
parent_cfg = None
|
||||
for i in range(3):
|
||||
config = generate_config(tid)
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=i), {})
|
||||
|
||||
# Pre-delete: verify data exists
|
||||
assert await saver.aget_tuple(generate_config(tid)) is not None, (
|
||||
"Pre-delete: checkpoint should exist"
|
||||
)
|
||||
|
||||
await saver.adelete_thread(tid)
|
||||
|
||||
tup = await saver.aget_tuple(generate_config(tid))
|
||||
assert tup is None
|
||||
|
||||
results = []
|
||||
async for t in saver.alist(generate_config(tid)):
|
||||
results.append(t)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
async def test_delete_thread_removes_writes(saver: BaseCheckpointSaver) -> None:
|
||||
"""Pending writes gone after delete."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
await saver.aput_writes(stored, [("ch", "val")], str(uuid4()))
|
||||
|
||||
# Pre-delete: verify writes exist
|
||||
pre_tup = await saver.aget_tuple(generate_config(tid))
|
||||
assert pre_tup is not None, "Pre-delete: checkpoint should exist"
|
||||
assert pre_tup.pending_writes is not None and len(pre_tup.pending_writes) == 1, (
|
||||
f"Pre-delete: expected 1 write, got {len(pre_tup.pending_writes) if pre_tup.pending_writes else 0}"
|
||||
)
|
||||
|
||||
await saver.adelete_thread(tid)
|
||||
|
||||
tup = await saver.aget_tuple(generate_config(tid))
|
||||
assert tup is None
|
||||
|
||||
|
||||
async def test_delete_thread_removes_all_namespaces(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Root + child namespaces both removed."""
|
||||
tid = str(uuid4())
|
||||
|
||||
for ns in ["", "child:1"]:
|
||||
cfg = generate_config(tid, checkpoint_ns=ns)
|
||||
cp = generate_checkpoint()
|
||||
await saver.aput(cfg, cp, generate_metadata(), {})
|
||||
|
||||
# Pre-delete: verify each namespace has data
|
||||
for ns in ["", "child:1"]:
|
||||
pre = await saver.aget_tuple(generate_config(tid, checkpoint_ns=ns))
|
||||
assert pre is not None, f"Pre-delete: namespace '{ns}' should have data"
|
||||
|
||||
await saver.adelete_thread(tid)
|
||||
|
||||
for ns in ["", "child:1"]:
|
||||
tup = await saver.aget_tuple(generate_config(tid, checkpoint_ns=ns))
|
||||
assert tup is None
|
||||
|
||||
|
||||
async def test_delete_thread_preserves_other_threads(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Other threads untouched."""
|
||||
tid1, tid2 = str(uuid4()), str(uuid4())
|
||||
|
||||
for tid in (tid1, tid2):
|
||||
cfg = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
await saver.aput(cfg, cp, generate_metadata(), {})
|
||||
|
||||
await saver.adelete_thread(tid1)
|
||||
|
||||
assert await saver.aget_tuple(generate_config(tid1)) is None
|
||||
assert await saver.aget_tuple(generate_config(tid2)) is not None
|
||||
|
||||
|
||||
async def test_delete_thread_nonexistent_noop(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""No error for missing thread."""
|
||||
# Should not raise
|
||||
await saver.adelete_thread(str(uuid4()))
|
||||
|
||||
|
||||
ALL_DELETE_THREAD_TESTS = [
|
||||
test_delete_thread_removes_checkpoints,
|
||||
test_delete_thread_removes_writes,
|
||||
test_delete_thread_removes_all_namespaces,
|
||||
test_delete_thread_preserves_other_threads,
|
||||
test_delete_thread_nonexistent_noop,
|
||||
]
|
||||
|
||||
|
||||
async def run_delete_thread_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all delete_thread tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_DELETE_THREAD_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("delete_thread", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"delete_thread", test_fn.__name__, False, traceback.format_exc()
|
||||
)
|
||||
return passed, failed, failures
|
||||
@@ -0,0 +1,253 @@
|
||||
"""GET_TUPLE capability tests — aget_tuple retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tuple_nonexistent_returns_none(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Missing thread returns None."""
|
||||
config = generate_config(str(uuid4()))
|
||||
tup = await saver.aget_tuple(config)
|
||||
assert tup is None
|
||||
|
||||
|
||||
async def test_get_tuple_latest_when_no_checkpoint_id(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Returns newest checkpoint when no checkpoint_id in config."""
|
||||
tid = str(uuid4())
|
||||
ids = []
|
||||
parent_cfg = None
|
||||
for i in range(3):
|
||||
config = generate_config(tid)
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=i), {})
|
||||
ids.append(cp["id"])
|
||||
|
||||
# Get without checkpoint_id — should return the latest
|
||||
tup = await saver.aget_tuple(generate_config(tid))
|
||||
assert tup is not None
|
||||
assert tup.checkpoint["id"] == ids[-1]
|
||||
assert tup.metadata["step"] == 2, (
|
||||
f"Expected latest step=2, got {tup.metadata['step']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tuple_specific_checkpoint_id(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Returns exact match when checkpoint_id specified."""
|
||||
tid = str(uuid4())
|
||||
|
||||
config1 = generate_config(tid)
|
||||
cp1 = generate_checkpoint()
|
||||
stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {})
|
||||
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint()
|
||||
await saver.aput(config2, cp2, generate_metadata(step=1), {})
|
||||
|
||||
# Fetch the first one specifically
|
||||
tup = await saver.aget_tuple(stored1)
|
||||
assert tup is not None
|
||||
assert tup.checkpoint["id"] == cp1["id"]
|
||||
|
||||
|
||||
async def test_get_tuple_config_structure(saver: BaseCheckpointSaver) -> None:
|
||||
"""tuple.config has thread_id, checkpoint_ns, checkpoint_id."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
conf = tup.config["configurable"]
|
||||
assert conf["thread_id"] == tid
|
||||
assert conf.get("checkpoint_ns", "") == "", (
|
||||
f"Expected checkpoint_ns='', got {conf.get('checkpoint_ns')!r}"
|
||||
)
|
||||
assert conf["checkpoint_id"] == cp["id"]
|
||||
|
||||
|
||||
async def test_get_tuple_checkpoint_fields(saver: BaseCheckpointSaver) -> None:
|
||||
"""All Checkpoint fields present."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint(channel_values={"k": "v"})
|
||||
cp["channel_versions"] = {"k": 1}
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {"k": 1})
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
c = tup.checkpoint
|
||||
assert c["id"] == cp["id"], f"id mismatch: {c['id']!r} != {cp['id']!r}"
|
||||
assert c["v"] == 1, f"Expected v=1, got {c['v']!r}"
|
||||
assert "ts" in c and c["ts"], "ts should be non-empty"
|
||||
assert c["channel_values"] == {"k": "v"}, f"channel_values: {c['channel_values']!r}"
|
||||
assert "channel_versions" in c
|
||||
assert "versions_seen" in c
|
||||
|
||||
|
||||
async def test_get_tuple_metadata(saver: BaseCheckpointSaver) -> None:
|
||||
"""metadata populated correctly."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata(source="input", step=-1)
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.metadata["source"] == "input"
|
||||
assert tup.metadata["step"] == -1
|
||||
|
||||
|
||||
async def test_get_tuple_parent_config(saver: BaseCheckpointSaver) -> None:
|
||||
"""parent_config when parent exists, None otherwise."""
|
||||
tid = str(uuid4())
|
||||
|
||||
# First checkpoint — no parent
|
||||
config1 = generate_config(tid)
|
||||
cp1 = generate_checkpoint()
|
||||
stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {})
|
||||
|
||||
tup1 = await saver.aget_tuple(stored1)
|
||||
assert tup1 is not None
|
||||
assert tup1.parent_config is None
|
||||
|
||||
# Second checkpoint — has parent
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint()
|
||||
stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {})
|
||||
|
||||
tup2 = await saver.aget_tuple(stored2)
|
||||
assert tup2 is not None
|
||||
assert tup2.parent_config is not None
|
||||
assert (
|
||||
tup2.parent_config["configurable"]["checkpoint_id"]
|
||||
== stored1["configurable"]["checkpoint_id"]
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tuple_pending_writes(saver: BaseCheckpointSaver) -> None:
|
||||
"""pending_writes from put_writes visible."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
await saver.aput_writes(stored, [("ch", "val")], task_id)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 1, (
|
||||
f"Expected 1 write, got {len(tup.pending_writes)}"
|
||||
)
|
||||
assert tup.pending_writes[0][0] == task_id, (
|
||||
f"task_id mismatch: {tup.pending_writes[0][0]!r}"
|
||||
)
|
||||
assert tup.pending_writes[0][1] == "ch", (
|
||||
f"channel mismatch: {tup.pending_writes[0][1]!r}"
|
||||
)
|
||||
assert tup.pending_writes[0][2] == "val", (
|
||||
f"value mismatch: {tup.pending_writes[0][2]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tuple_respects_namespace(saver: BaseCheckpointSaver) -> None:
|
||||
"""checkpoint_ns filtering."""
|
||||
tid = str(uuid4())
|
||||
|
||||
cfg_root = generate_config(tid, checkpoint_ns="")
|
||||
cp_root = generate_checkpoint()
|
||||
stored_root = await saver.aput(cfg_root, cp_root, generate_metadata(), {})
|
||||
|
||||
cfg_child = generate_config(tid, checkpoint_ns="child:1")
|
||||
cp_child = generate_checkpoint()
|
||||
stored_child = await saver.aput(cfg_child, cp_child, generate_metadata(), {})
|
||||
|
||||
tup_root = await saver.aget_tuple(stored_root)
|
||||
assert tup_root is not None
|
||||
assert tup_root.checkpoint["id"] == cp_root["id"]
|
||||
|
||||
tup_child = await saver.aget_tuple(stored_child)
|
||||
assert tup_child is not None
|
||||
assert tup_child.checkpoint["id"] == cp_child["id"]
|
||||
|
||||
|
||||
async def test_get_tuple_nonexistent_checkpoint_id(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Specific but missing checkpoint_id returns None."""
|
||||
tid = str(uuid4())
|
||||
nonexistent_id = str(uuid4())
|
||||
# Put one checkpoint so the thread exists
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
# Ask for a non-existent checkpoint_id
|
||||
bad_cfg = generate_config(tid, checkpoint_id=nonexistent_id)
|
||||
tup = await saver.aget_tuple(bad_cfg)
|
||||
assert tup is None
|
||||
|
||||
|
||||
ALL_GET_TUPLE_TESTS = [
|
||||
test_get_tuple_nonexistent_returns_none,
|
||||
test_get_tuple_latest_when_no_checkpoint_id,
|
||||
test_get_tuple_specific_checkpoint_id,
|
||||
test_get_tuple_config_structure,
|
||||
test_get_tuple_checkpoint_fields,
|
||||
test_get_tuple_metadata,
|
||||
test_get_tuple_parent_config,
|
||||
test_get_tuple_pending_writes,
|
||||
test_get_tuple_respects_namespace,
|
||||
test_get_tuple_nonexistent_checkpoint_id,
|
||||
]
|
||||
|
||||
|
||||
async def run_get_tuple_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all get_tuple tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_GET_TUPLE_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("get_tuple", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"get_tuple", test_fn.__name__, False, traceback.format_exc()
|
||||
)
|
||||
return passed, failed, failures
|
||||
@@ -0,0 +1,414 @@
|
||||
"""LIST capability tests — alist with various filters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_list_data(saver: BaseCheckpointSaver) -> dict:
|
||||
"""Populate saver with test data for list tests. Returns lookup info."""
|
||||
tid = str(uuid4())
|
||||
ids = []
|
||||
parent_cfg = None
|
||||
for i in range(4):
|
||||
config = generate_config(tid)
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
source = "input" if i % 2 == 0 else "loop"
|
||||
md = generate_metadata(source=source, step=i)
|
||||
parent_cfg = await saver.aput(config, cp, md, {})
|
||||
ids.append(cp["id"])
|
||||
|
||||
return {
|
||||
"thread_id": tid,
|
||||
"checkpoint_ids": ids,
|
||||
"latest_config": parent_cfg,
|
||||
}
|
||||
|
||||
|
||||
async def test_list_all(saver: BaseCheckpointSaver) -> None:
|
||||
"""No filters returns all checkpoints for the thread."""
|
||||
data = await _setup_list_data(saver)
|
||||
tid = data["thread_id"]
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 4
|
||||
|
||||
|
||||
async def test_list_by_thread(saver: BaseCheckpointSaver) -> None:
|
||||
"""Filter by thread_id — other threads not returned."""
|
||||
data = await _setup_list_data(saver)
|
||||
|
||||
# List for a non-existent thread
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(str(uuid4()))):
|
||||
results.append(tup)
|
||||
assert len(results) == 0
|
||||
|
||||
# List for actual thread
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(data["thread_id"])):
|
||||
results.append(tup)
|
||||
assert len(results) == 4
|
||||
|
||||
|
||||
async def test_list_by_namespace(saver: BaseCheckpointSaver) -> None:
|
||||
"""Filter by checkpoint_ns."""
|
||||
tid = str(uuid4())
|
||||
|
||||
# Root namespace
|
||||
cfg1 = generate_config(tid, checkpoint_ns="")
|
||||
cp1 = generate_checkpoint()
|
||||
await saver.aput(cfg1, cp1, generate_metadata(), {})
|
||||
|
||||
# Child namespace
|
||||
cfg2 = generate_config(tid, checkpoint_ns="child:1")
|
||||
cp2 = generate_checkpoint()
|
||||
await saver.aput(cfg2, cp2, generate_metadata(), {})
|
||||
|
||||
root_results = []
|
||||
async for tup in saver.alist(generate_config(tid, checkpoint_ns="")):
|
||||
root_results.append(tup)
|
||||
assert len(root_results) == 1
|
||||
|
||||
child_results = []
|
||||
async for tup in saver.alist(generate_config(tid, checkpoint_ns="child:1")):
|
||||
child_results.append(tup)
|
||||
assert len(child_results) == 1
|
||||
|
||||
|
||||
async def test_list_ordering(saver: BaseCheckpointSaver) -> None:
|
||||
"""Newest first (descending checkpoint_id)."""
|
||||
data = await _setup_list_data(saver)
|
||||
ids = data["checkpoint_ids"]
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(data["thread_id"])):
|
||||
results.append(tup.checkpoint["id"])
|
||||
|
||||
# Should be in reverse order (newest first)
|
||||
assert results == list(reversed(ids))
|
||||
|
||||
|
||||
async def test_list_metadata_filter_single_key(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""filter={'source': 'input'} returns only input checkpoints."""
|
||||
data = await _setup_list_data(saver)
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(data["thread_id"]),
|
||||
filter={"source": "input"},
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 2, (
|
||||
f"Expected 2 'input' checkpoints (steps 0,2), got {len(results)}"
|
||||
)
|
||||
for tup in results:
|
||||
assert tup.metadata["source"] == "input"
|
||||
|
||||
|
||||
async def test_list_metadata_filter_step(saver: BaseCheckpointSaver) -> None:
|
||||
"""filter={'step': 1} returns matching checkpoints."""
|
||||
data = await _setup_list_data(saver)
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(data["thread_id"]),
|
||||
filter={"step": 1},
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].metadata["step"] == 1
|
||||
|
||||
|
||||
async def test_list_before(saver: BaseCheckpointSaver) -> None:
|
||||
"""Pagination cursor — only checkpoints before the given one."""
|
||||
data = await _setup_list_data(saver)
|
||||
ids = data["checkpoint_ids"]
|
||||
|
||||
# Use the 3rd checkpoint as the 'before' cursor (index 2)
|
||||
before_cfg = generate_config(data["thread_id"], checkpoint_id=ids[2])
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(data["thread_id"]),
|
||||
before=before_cfg,
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
# Should only include checkpoints before ids[2]
|
||||
result_ids = [t.checkpoint["id"] for t in results]
|
||||
assert ids[2] not in result_ids
|
||||
assert ids[3] not in result_ids
|
||||
assert set(result_ids) == {ids[0], ids[1]}, (
|
||||
f"Expected {{ids[0], ids[1]}}, got {set(result_ids)}"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_limit(saver: BaseCheckpointSaver) -> None:
|
||||
"""limit=1, limit=N."""
|
||||
data = await _setup_list_data(saver)
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(data["thread_id"]), limit=1):
|
||||
results.append(tup)
|
||||
assert len(results) == 1
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(data["thread_id"]), limit=2):
|
||||
results.append(tup)
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
async def test_list_limit_plus_before(saver: BaseCheckpointSaver) -> None:
|
||||
"""Pagination with limit."""
|
||||
data = await _setup_list_data(saver)
|
||||
ids = data["checkpoint_ids"]
|
||||
|
||||
before_cfg = generate_config(data["thread_id"], checkpoint_id=ids[3])
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(data["thread_id"]),
|
||||
before=before_cfg,
|
||||
limit=1,
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].checkpoint["id"] == ids[2]
|
||||
|
||||
|
||||
async def test_list_combined_thread_and_filter(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""thread_id + metadata filter combined."""
|
||||
data = await _setup_list_data(saver)
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(data["thread_id"]),
|
||||
filter={"source": "loop"},
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 2, (
|
||||
f"Expected 2 'loop' checkpoints (steps 1,3), got {len(results)}"
|
||||
)
|
||||
for tup in results:
|
||||
assert tup.metadata["source"] == "loop"
|
||||
|
||||
|
||||
async def test_list_empty_result(saver: BaseCheckpointSaver) -> None:
|
||||
"""No matches returns empty."""
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(str(uuid4())),
|
||||
filter={"source": "nonexistent"},
|
||||
):
|
||||
results.append(tup)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
async def test_list_includes_pending_writes(saver: BaseCheckpointSaver) -> None:
|
||||
"""pending_writes in listed tuples."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
await saver.aput_writes(stored, [("ch", "val")], str(uuid4()))
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].pending_writes is not None
|
||||
assert len(results[0].pending_writes) == 1, (
|
||||
f"Expected 1 write, got {len(results[0].pending_writes)}"
|
||||
)
|
||||
assert results[0].pending_writes[0][1] == "ch", (
|
||||
f"channel mismatch: {results[0].pending_writes[0][1]!r}"
|
||||
)
|
||||
assert results[0].pending_writes[0][2] == "val", (
|
||||
f"value mismatch: {results[0].pending_writes[0][2]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_list_multiple_namespaces(saver: BaseCheckpointSaver) -> None:
|
||||
"""Root namespace checkpoint listed correctly."""
|
||||
tid = str(uuid4())
|
||||
|
||||
for ns in ["", "child:1", "child:2"]:
|
||||
cfg = generate_config(tid, checkpoint_ns=ns)
|
||||
cp = generate_checkpoint()
|
||||
await saver.aput(cfg, cp, generate_metadata(), {})
|
||||
|
||||
# List with root namespace filter — should return exactly the root checkpoint
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid, checkpoint_ns="")):
|
||||
results.append(tup)
|
||||
assert len(results) == 1, f"Expected 1 root checkpoint, got {len(results)}"
|
||||
|
||||
|
||||
async def test_list_metadata_filter_multiple_keys(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""filter with multiple keys — all must match."""
|
||||
tid = str(uuid4())
|
||||
|
||||
# Create checkpoints with different metadata combos
|
||||
for source, step in [("input", 1), ("loop", 1), ("input", 2)]:
|
||||
cfg = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
await saver.aput(cfg, cp, generate_metadata(source=source, step=step), {})
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(tid),
|
||||
filter={"source": "input", "step": 2},
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 1, (
|
||||
f"Expected 1 match for source=input+step=2, got {len(results)}"
|
||||
)
|
||||
assert results[0].metadata["source"] == "input"
|
||||
assert results[0].metadata["step"] == 2
|
||||
|
||||
|
||||
async def test_list_metadata_filter_no_match(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Multi-key filter that matches nothing returns empty."""
|
||||
data = await _setup_list_data(saver)
|
||||
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(data["thread_id"]),
|
||||
filter={"source": "update", "step": 99},
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
async def test_list_metadata_custom_keys(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Custom (non-standard) metadata keys are filterable."""
|
||||
tid = str(uuid4())
|
||||
|
||||
cfg = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
await saver.aput(cfg, cp, generate_metadata(score=42, run_id="run-abc"), {})
|
||||
|
||||
cfg2 = generate_config(tid)
|
||||
cp2 = generate_checkpoint()
|
||||
await saver.aput(cfg2, cp2, generate_metadata(score=99, run_id="run-xyz"), {})
|
||||
|
||||
# Filter by custom key
|
||||
results = []
|
||||
async for tup in saver.alist(
|
||||
generate_config(tid),
|
||||
filter={"score": 42},
|
||||
):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].metadata["score"] == 42
|
||||
assert results[0].metadata["run_id"] == "run-abc"
|
||||
|
||||
|
||||
async def test_list_global_search(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""alist(None, filter=...) searches across all threads."""
|
||||
tid1, tid2 = str(uuid4()), str(uuid4())
|
||||
|
||||
# Use a unique marker so we don't collide with other tests' data
|
||||
marker = str(uuid4())
|
||||
|
||||
cfg1 = generate_config(tid1)
|
||||
cp1 = generate_checkpoint()
|
||||
await saver.aput(cfg1, cp1, generate_metadata(source="input", marker=marker), {})
|
||||
|
||||
cfg2 = generate_config(tid2)
|
||||
cp2 = generate_checkpoint()
|
||||
await saver.aput(cfg2, cp2, generate_metadata(source="loop", marker=marker), {})
|
||||
|
||||
# Search across all threads with filter
|
||||
results = []
|
||||
async for tup in saver.alist(None, filter={"source": "input", "marker": marker}):
|
||||
results.append(tup)
|
||||
assert len(results) == 1
|
||||
assert results[0].config["configurable"]["thread_id"] == tid1
|
||||
|
||||
# Search with marker only — should find both
|
||||
results = []
|
||||
async for tup in saver.alist(None, filter={"marker": marker}):
|
||||
results.append(tup)
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
ALL_LIST_TESTS = [
|
||||
test_list_all,
|
||||
test_list_by_thread,
|
||||
test_list_by_namespace,
|
||||
test_list_ordering,
|
||||
test_list_metadata_filter_single_key,
|
||||
test_list_metadata_filter_step,
|
||||
test_list_metadata_filter_multiple_keys,
|
||||
test_list_metadata_filter_no_match,
|
||||
test_list_metadata_custom_keys,
|
||||
test_list_global_search,
|
||||
test_list_before,
|
||||
test_list_limit,
|
||||
test_list_limit_plus_before,
|
||||
test_list_combined_thread_and_filter,
|
||||
test_list_empty_result,
|
||||
test_list_includes_pending_writes,
|
||||
test_list_multiple_namespaces,
|
||||
]
|
||||
|
||||
|
||||
async def run_list_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all list tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_LIST_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("list", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result("list", test_fn.__name__, False, traceback.format_exc())
|
||||
return passed, failed, failures
|
||||
@@ -0,0 +1,217 @@
|
||||
"""PRUNE capability tests — aprune(strategy)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def _setup_thread(saver: BaseCheckpointSaver, tid: str, n: int = 3) -> list[dict]:
|
||||
"""Create n checkpoints on tid. Returns stored configs."""
|
||||
stored = []
|
||||
parent_cfg = None
|
||||
for i in range(n):
|
||||
config = generate_config(tid)
|
||||
if parent_cfg:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
parent_cfg = await saver.aput(config, cp, generate_metadata(step=i), {})
|
||||
stored.append(parent_cfg)
|
||||
return stored
|
||||
|
||||
|
||||
async def test_prune_keep_latest_single_thread(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Only latest checkpoint survives."""
|
||||
tid = str(uuid4())
|
||||
configs = await _setup_thread(saver, tid, n=4)
|
||||
|
||||
await saver.aprune([tid], strategy="keep_latest")
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
|
||||
assert len(results) == 1
|
||||
assert (
|
||||
results[0].config["configurable"]["checkpoint_id"]
|
||||
== configs[-1]["configurable"]["checkpoint_id"]
|
||||
)
|
||||
|
||||
|
||||
async def test_prune_keep_latest_multiple_threads(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Each thread keeps its latest."""
|
||||
tid1, tid2 = str(uuid4()), str(uuid4())
|
||||
c1 = await _setup_thread(saver, tid1, n=3)
|
||||
c2 = await _setup_thread(saver, tid2, n=2)
|
||||
|
||||
await saver.aprune([tid1, tid2], strategy="keep_latest")
|
||||
for tid, expected_last in [(tid1, c1[-1]), (tid2, c2[-1])]:
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
assert len(results) == 1
|
||||
assert (
|
||||
results[0].config["configurable"]["checkpoint_id"]
|
||||
== expected_last["configurable"]["checkpoint_id"]
|
||||
)
|
||||
|
||||
|
||||
async def test_prune_keep_latest_across_namespaces(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Latest per namespace kept."""
|
||||
tid = str(uuid4())
|
||||
|
||||
# Root namespace: 3 checkpoints
|
||||
parent = None
|
||||
for i in range(3):
|
||||
cfg = generate_config(tid, checkpoint_ns="")
|
||||
if parent:
|
||||
cfg["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
parent = await saver.aput(cfg, cp, generate_metadata(step=i), {})
|
||||
root_latest = parent
|
||||
|
||||
# Child namespace: 2 checkpoints
|
||||
parent = None
|
||||
for i in range(2):
|
||||
cfg = generate_config(tid, checkpoint_ns="child:1")
|
||||
if parent:
|
||||
cfg["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
parent = await saver.aput(cfg, cp, generate_metadata(step=i), {})
|
||||
child_latest = parent
|
||||
|
||||
assert root_latest is not None
|
||||
assert child_latest is not None
|
||||
await saver.aprune([tid], strategy="keep_latest")
|
||||
for ns, expected in [("", root_latest), ("child:1", child_latest)]:
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid, checkpoint_ns=ns)):
|
||||
results.append(tup)
|
||||
assert len(results) == 1
|
||||
assert (
|
||||
results[0].config["configurable"]["checkpoint_id"]
|
||||
== expected["configurable"]["checkpoint_id"]
|
||||
)
|
||||
|
||||
|
||||
async def test_prune_keep_latest_preserves_writes(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Latest checkpoint's writes kept."""
|
||||
tid = str(uuid4())
|
||||
configs = await _setup_thread(saver, tid, n=3)
|
||||
|
||||
# Add writes to the latest
|
||||
await saver.aput_writes(configs[-1], [("ch", "val")], str(uuid4()))
|
||||
|
||||
await saver.aprune([tid], strategy="keep_latest")
|
||||
tup = await saver.aget_tuple(generate_config(tid))
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 1, (
|
||||
f"Expected 1 write, got {len(tup.pending_writes)}"
|
||||
)
|
||||
assert tup.pending_writes[0][1] == "ch", (
|
||||
f"channel mismatch: {tup.pending_writes[0][1]!r}"
|
||||
)
|
||||
assert tup.pending_writes[0][2] == "val", (
|
||||
f"value mismatch: {tup.pending_writes[0][2]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_prune_delete_all(saver: BaseCheckpointSaver) -> None:
|
||||
"""delete_all strategy removes everything."""
|
||||
tid = str(uuid4())
|
||||
await _setup_thread(saver, tid, n=3)
|
||||
|
||||
await saver.aprune([tid], strategy="delete")
|
||||
results = []
|
||||
async for tup in saver.alist(generate_config(tid)):
|
||||
results.append(tup)
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
async def test_prune_preserves_other_threads(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Unlisted threads untouched."""
|
||||
tid1, tid2 = str(uuid4()), str(uuid4())
|
||||
await _setup_thread(saver, tid1, n=3)
|
||||
await _setup_thread(saver, tid2, n=2)
|
||||
|
||||
# Snapshot tid2 before prune
|
||||
pre_ids = []
|
||||
async for tup in saver.alist(generate_config(tid2)):
|
||||
pre_ids.append(tup.checkpoint["id"])
|
||||
|
||||
await saver.aprune([tid1], strategy="keep_latest")
|
||||
# tid2 should be fully intact — same checkpoint IDs
|
||||
post_ids = []
|
||||
async for tup in saver.alist(generate_config(tid2)):
|
||||
post_ids.append(tup.checkpoint["id"])
|
||||
assert post_ids == pre_ids, f"tid2 changed: {pre_ids} -> {post_ids}"
|
||||
|
||||
|
||||
async def test_prune_empty_list_noop(saver: BaseCheckpointSaver) -> None:
|
||||
"""Empty thread_ids no error."""
|
||||
await saver.aprune([], strategy="keep_latest")
|
||||
|
||||
|
||||
async def test_prune_nonexistent_noop(saver: BaseCheckpointSaver) -> None:
|
||||
"""Missing threads no error."""
|
||||
await saver.aprune([str(uuid4())], strategy="keep_latest")
|
||||
|
||||
|
||||
ALL_PRUNE_TESTS = [
|
||||
test_prune_keep_latest_single_thread,
|
||||
test_prune_keep_latest_multiple_threads,
|
||||
test_prune_keep_latest_across_namespaces,
|
||||
test_prune_keep_latest_preserves_writes,
|
||||
test_prune_delete_all,
|
||||
test_prune_preserves_other_threads,
|
||||
test_prune_empty_list_noop,
|
||||
test_prune_nonexistent_noop,
|
||||
]
|
||||
|
||||
|
||||
async def run_prune_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all prune tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_PRUNE_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("prune", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result("prune", test_fn.__name__, False, traceback.format_exc())
|
||||
return passed, failed, failures
|
||||
@@ -0,0 +1,411 @@
|
||||
"""PUT capability tests — aput + aget_tuple round-trip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
ChannelVersions,
|
||||
)
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def test_put_returns_config(saver: BaseCheckpointSaver) -> None:
|
||||
"""aput returns a RunnableConfig with thread_id, checkpoint_ns, checkpoint_id."""
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint(channel_values={"k": "v"})
|
||||
cp["channel_versions"] = {"k": 1}
|
||||
md = generate_metadata()
|
||||
|
||||
result = await saver.aput(config, cp, md, {"k": 1})
|
||||
|
||||
assert "configurable" in result
|
||||
conf = result["configurable"]
|
||||
assert "thread_id" in conf
|
||||
assert "checkpoint_ns" in conf
|
||||
assert "checkpoint_id" in conf
|
||||
assert conf["checkpoint_id"] == cp["id"]
|
||||
|
||||
|
||||
async def test_put_roundtrip(saver: BaseCheckpointSaver) -> None:
|
||||
"""put then get_tuple returns identical checkpoint."""
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint(channel_values={"msg": "hello"})
|
||||
cp["channel_versions"] = {"msg": 1}
|
||||
md = generate_metadata(source="input", step=-1)
|
||||
|
||||
stored_config = await saver.aput(config, cp, md, {"msg": 1})
|
||||
|
||||
tup = await saver.aget_tuple(stored_config)
|
||||
assert tup is not None
|
||||
assert tup.checkpoint["id"] == cp["id"]
|
||||
assert tup.checkpoint["channel_values"] == {"msg": "hello"}
|
||||
|
||||
|
||||
async def test_put_preserves_channel_values(saver: BaseCheckpointSaver) -> None:
|
||||
"""Various types (str, int, list, dict, bytes, None) round-trip correctly."""
|
||||
values: dict[str, Any] = {
|
||||
"str_val": "hello",
|
||||
"int_val": 42,
|
||||
"list_val": [1, 2, 3],
|
||||
"dict_val": {"nested": True},
|
||||
}
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint(channel_values=values)
|
||||
versions: ChannelVersions = {k: 1 for k in values}
|
||||
cp["channel_versions"] = versions
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, versions)
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
for k, v in values.items():
|
||||
assert tup.checkpoint["channel_values"].get(k) == v, (
|
||||
f"channel_values[{k}]: expected {v!r}, got {tup.checkpoint['channel_values'].get(k)!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_preserves_channel_versions(saver: BaseCheckpointSaver) -> None:
|
||||
"""ChannelVersions round-trip correctly."""
|
||||
versions: ChannelVersions = {"a": 1, "b": 2}
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint(
|
||||
channel_values={"a": "x", "b": "y"}, channel_versions=versions
|
||||
)
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, versions)
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
# Compare version values — checkpointers may convert int to str
|
||||
for k, expected in versions.items():
|
||||
actual = tup.checkpoint["channel_versions"].get(k)
|
||||
assert actual is not None, f"channel_versions[{k}] missing"
|
||||
assert str(actual).split(".")[0] == str(expected).split(".")[0], (
|
||||
f"channel_versions[{k}]: expected {expected!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_preserves_versions_seen(saver: BaseCheckpointSaver) -> None:
|
||||
"""versions_seen dict round-trips."""
|
||||
vs: dict[str, ChannelVersions] = {"node1": {"ch": 1}, "node2": {"ch": 2}}
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint(versions_seen=vs)
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
for node in vs:
|
||||
assert node in tup.checkpoint["versions_seen"], f"versions_seen[{node}] missing"
|
||||
|
||||
|
||||
async def test_put_preserves_metadata(saver: BaseCheckpointSaver) -> None:
|
||||
"""Metadata source, step, parents, and custom keys round-trip."""
|
||||
md = generate_metadata(source="loop", step=3, custom_key="custom_value")
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint()
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.metadata["source"] == "loop"
|
||||
assert tup.metadata["step"] == 3
|
||||
assert tup.metadata.get("custom_key") == "custom_value"
|
||||
|
||||
|
||||
async def test_put_root_namespace(saver: BaseCheckpointSaver) -> None:
|
||||
"""checkpoint_ns='' works."""
|
||||
config = generate_config(checkpoint_ns="")
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.config["configurable"].get("checkpoint_ns", "") == ""
|
||||
|
||||
|
||||
async def test_put_child_namespace(saver: BaseCheckpointSaver) -> None:
|
||||
"""checkpoint_ns='child:abc' works."""
|
||||
config = generate_config(checkpoint_ns="child:abc")
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.config["configurable"]["checkpoint_ns"] == "child:abc"
|
||||
|
||||
|
||||
async def test_put_default_namespace(saver: BaseCheckpointSaver) -> None:
|
||||
"""Config without checkpoint_ns defaults to ''."""
|
||||
tid = str(uuid4())
|
||||
config = {"configurable": {"thread_id": tid, "checkpoint_ns": ""}}
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
|
||||
|
||||
async def test_put_multiple_checkpoints_same_thread(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Sequential puts on same thread, all retrievable."""
|
||||
tid = str(uuid4())
|
||||
ids = []
|
||||
parent_cfg = None
|
||||
for i in range(3):
|
||||
config = generate_config(tid)
|
||||
if parent_cfg is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent_cfg["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata(step=i)
|
||||
parent_cfg = await saver.aput(config, cp, md, {})
|
||||
ids.append(cp["id"])
|
||||
|
||||
# All three should be retrievable
|
||||
for cid in ids:
|
||||
cfg = generate_config(tid, checkpoint_id=cid)
|
||||
tup = await saver.aget_tuple(cfg)
|
||||
assert tup is not None, f"checkpoint {cid} not found"
|
||||
assert tup.checkpoint["id"] == cid
|
||||
|
||||
|
||||
async def test_put_multiple_threads_isolated(saver: BaseCheckpointSaver) -> None:
|
||||
"""Different thread_ids don't interfere."""
|
||||
tid1, tid2 = str(uuid4()), str(uuid4())
|
||||
|
||||
config1 = generate_config(tid1)
|
||||
cp1 = generate_checkpoint(channel_values={"x": "thread1"})
|
||||
cp1["channel_versions"] = {"x": 1}
|
||||
await saver.aput(config1, cp1, generate_metadata(), {"x": 1})
|
||||
|
||||
config2 = generate_config(tid2)
|
||||
cp2 = generate_checkpoint(channel_values={"x": "thread2"})
|
||||
cp2["channel_versions"] = {"x": 1}
|
||||
await saver.aput(config2, cp2, generate_metadata(), {"x": 1})
|
||||
|
||||
tup1 = await saver.aget_tuple(generate_config(tid1))
|
||||
tup2 = await saver.aget_tuple(generate_config(tid2))
|
||||
assert tup1 is not None and tup2 is not None
|
||||
assert tup1.checkpoint["channel_values"]["x"] == "thread1"
|
||||
assert tup2.checkpoint["channel_values"]["x"] == "thread2"
|
||||
|
||||
|
||||
async def test_put_parent_config(saver: BaseCheckpointSaver) -> None:
|
||||
"""parent checkpoint_id tracked correctly."""
|
||||
tid = str(uuid4())
|
||||
config1 = generate_config(tid)
|
||||
cp1 = generate_checkpoint()
|
||||
stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {})
|
||||
|
||||
# Second checkpoint — its config carries the parent checkpoint_id
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint()
|
||||
stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {})
|
||||
|
||||
tup = await saver.aget_tuple(stored2)
|
||||
assert tup is not None
|
||||
assert tup.parent_config is not None
|
||||
assert (
|
||||
tup.parent_config["configurable"]["checkpoint_id"]
|
||||
== stored1["configurable"]["checkpoint_id"]
|
||||
)
|
||||
|
||||
|
||||
async def test_put_incremental_channel_update(saver: BaseCheckpointSaver) -> None:
|
||||
"""Only updated channels need new blobs; unchanged channels loaded from prior versions."""
|
||||
tid = str(uuid4())
|
||||
|
||||
# Checkpoint 1: both channels are new
|
||||
config1 = generate_config(tid)
|
||||
cp1 = generate_checkpoint(
|
||||
channel_values={"a": "v1", "b": "v2"},
|
||||
channel_versions={"a": 1, "b": 1},
|
||||
)
|
||||
stored1 = await saver.aput(
|
||||
config1, cp1, generate_metadata(step=0), {"a": 1, "b": 1}
|
||||
)
|
||||
|
||||
# Checkpoint 2: only 'a' is updated
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint(
|
||||
channel_values={"a": "v1_updated", "b": "v2"},
|
||||
channel_versions={"a": 2, "b": 1},
|
||||
)
|
||||
stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {"a": 2})
|
||||
|
||||
# cp2 should reconstruct full channel_values from blobs at mixed versions
|
||||
tup2 = await saver.aget_tuple(stored2)
|
||||
assert tup2 is not None
|
||||
assert tup2.checkpoint["channel_values"].get("a") == "v1_updated", (
|
||||
f"a: expected 'v1_updated', got {tup2.checkpoint['channel_values'].get('a')!r}"
|
||||
)
|
||||
assert tup2.checkpoint["channel_values"].get("b") == "v2", (
|
||||
f"b: expected 'v2', got {tup2.checkpoint['channel_values'].get('b')!r}"
|
||||
)
|
||||
|
||||
# cp1 should still return original values
|
||||
tup1 = await saver.aget_tuple(stored1)
|
||||
assert tup1 is not None
|
||||
assert tup1.checkpoint["channel_values"].get("a") == "v1"
|
||||
assert tup1.checkpoint["channel_values"].get("b") == "v2"
|
||||
|
||||
|
||||
async def test_put_new_channel_added(saver: BaseCheckpointSaver) -> None:
|
||||
"""A channel that appears for the first time in a later checkpoint."""
|
||||
tid = str(uuid4())
|
||||
|
||||
config1 = generate_config(tid)
|
||||
cp1 = generate_checkpoint(
|
||||
channel_values={"a": "v1"},
|
||||
channel_versions={"a": 1},
|
||||
)
|
||||
stored1 = await saver.aput(config1, cp1, generate_metadata(step=0), {"a": 1})
|
||||
|
||||
# Checkpoint 2: 'b' is brand new, 'a' is unchanged
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint(
|
||||
channel_values={"a": "v1", "b": "new_channel"},
|
||||
channel_versions={"a": 1, "b": 1},
|
||||
)
|
||||
stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {"b": 1})
|
||||
|
||||
tup2 = await saver.aget_tuple(stored2)
|
||||
assert tup2 is not None
|
||||
assert tup2.checkpoint["channel_values"].get("a") == "v1", (
|
||||
f"a: expected 'v1', got {tup2.checkpoint['channel_values'].get('a')!r}"
|
||||
)
|
||||
assert tup2.checkpoint["channel_values"].get("b") == "new_channel", (
|
||||
f"b: expected 'new_channel', got {tup2.checkpoint['channel_values'].get('b')!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_channel_removed(saver: BaseCheckpointSaver) -> None:
|
||||
"""Channel no longer in channel_versions should not appear in loaded values."""
|
||||
tid = str(uuid4())
|
||||
|
||||
config1 = generate_config(tid)
|
||||
cp1 = generate_checkpoint(
|
||||
channel_values={"a": "v1", "b": "v2"},
|
||||
channel_versions={"a": 1, "b": 1},
|
||||
)
|
||||
stored1 = await saver.aput(
|
||||
config1, cp1, generate_metadata(step=0), {"a": 1, "b": 1}
|
||||
)
|
||||
|
||||
# Checkpoint 2: 'b' dropped from channel_versions
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint(
|
||||
channel_values={"a": "v1_updated"},
|
||||
channel_versions={"a": 2},
|
||||
)
|
||||
stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {"a": 2})
|
||||
|
||||
tup2 = await saver.aget_tuple(stored2)
|
||||
assert tup2 is not None
|
||||
assert tup2.checkpoint["channel_values"].get("a") == "v1_updated"
|
||||
assert "b" not in tup2.checkpoint["channel_values"], (
|
||||
f"'b' should not be present, got {tup2.checkpoint['channel_values']}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_preserves_run_id(saver: BaseCheckpointSaver) -> None:
|
||||
"""run_id in metadata round-trips correctly."""
|
||||
run_id = str(uuid4())
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint()
|
||||
md = generate_metadata(source="loop", step=0, run_id=run_id)
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.metadata.get("run_id") == run_id, (
|
||||
f"run_id: expected {run_id!r}, got {tup.metadata.get('run_id')!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_preserves_versions_seen_values(saver: BaseCheckpointSaver) -> None:
|
||||
"""versions_seen values (not just keys) round-trip correctly."""
|
||||
vs: dict[str, ChannelVersions] = {
|
||||
"node1": {"ch_a": 1, "ch_b": 2},
|
||||
"node2": {"ch_a": 3},
|
||||
}
|
||||
config = generate_config()
|
||||
cp = generate_checkpoint(versions_seen=vs)
|
||||
md = generate_metadata()
|
||||
|
||||
stored = await saver.aput(config, cp, md, {})
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
for node, expected_versions in vs.items():
|
||||
assert node in tup.checkpoint["versions_seen"], f"versions_seen[{node}] missing"
|
||||
actual_versions = tup.checkpoint["versions_seen"][node]
|
||||
for ch, expected_v in expected_versions.items():
|
||||
actual_v = actual_versions.get(ch)
|
||||
assert actual_v is not None, f"versions_seen[{node}][{ch}] missing"
|
||||
assert str(actual_v).split(".")[0] == str(expected_v).split(".")[0], (
|
||||
f"versions_seen[{node}][{ch}]: expected {expected_v!r}, got {actual_v!r}"
|
||||
)
|
||||
|
||||
|
||||
ALL_PUT_TESTS = [
|
||||
test_put_returns_config,
|
||||
test_put_roundtrip,
|
||||
test_put_preserves_channel_values,
|
||||
test_put_preserves_channel_versions,
|
||||
test_put_preserves_versions_seen,
|
||||
test_put_preserves_metadata,
|
||||
test_put_root_namespace,
|
||||
test_put_child_namespace,
|
||||
test_put_default_namespace,
|
||||
test_put_multiple_checkpoints_same_thread,
|
||||
test_put_multiple_threads_isolated,
|
||||
test_put_parent_config,
|
||||
test_put_incremental_channel_update,
|
||||
test_put_new_channel_added,
|
||||
test_put_channel_removed,
|
||||
test_put_preserves_run_id,
|
||||
test_put_preserves_versions_seen_values,
|
||||
]
|
||||
|
||||
|
||||
async def run_put_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all put tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_PUT_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("put", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result("put", test_fn.__name__, False, traceback.format_exc())
|
||||
return passed, failed, failures
|
||||
@@ -0,0 +1,302 @@
|
||||
"""PUT_WRITES capability tests — aput_writes + pending_writes retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
|
||||
from langgraph.checkpoint.conformance.test_utils import (
|
||||
generate_checkpoint,
|
||||
generate_config,
|
||||
generate_metadata,
|
||||
)
|
||||
|
||||
|
||||
async def test_put_writes_basic(saver: BaseCheckpointSaver) -> None:
|
||||
"""Write stored, visible in aget_tuple pending_writes."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
await saver.aput_writes(stored, [("channel1", "value1")], task_id)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
# Verify exact write tuple: (task_id, channel, value)
|
||||
matching = [w for w in tup.pending_writes if w[0] == task_id and w[1] == "channel1"]
|
||||
assert len(matching) == 1, f"Expected 1 write, got {len(matching)}"
|
||||
assert matching[0][2] == "value1", f"Value mismatch: {matching[0][2]!r}"
|
||||
|
||||
|
||||
async def test_put_writes_multiple_writes_same_task(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Multiple (channel, value) pairs in a single call."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
writes = [("ch1", "v1"), ("ch2", "v2"), ("ch3", "v3")]
|
||||
await saver.aput_writes(stored, writes, task_id)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 3, (
|
||||
f"Expected 3 writes, got {len(tup.pending_writes)}"
|
||||
)
|
||||
channels = {w[1] for w in tup.pending_writes}
|
||||
assert channels == {"ch1", "ch2", "ch3"}, f"Expected exact channels, got {channels}"
|
||||
# Verify values per channel
|
||||
for expected_ch, expected_val in writes:
|
||||
match = [
|
||||
w for w in tup.pending_writes if w[0] == task_id and w[1] == expected_ch
|
||||
]
|
||||
assert len(match) == 1, f"Expected 1 write for {expected_ch}, got {len(match)}"
|
||||
assert match[0][2] == expected_val, (
|
||||
f"Value mismatch for {expected_ch}: {match[0][2]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_writes_multiple_tasks(saver: BaseCheckpointSaver) -> None:
|
||||
"""Different task_ids produce separate writes."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
t1, t2 = str(uuid4()), str(uuid4())
|
||||
await saver.aput_writes(stored, [("ch", "from_t1")], t1)
|
||||
await saver.aput_writes(stored, [("ch", "from_t2")], t2)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 2, (
|
||||
f"Expected 2 writes, got {len(tup.pending_writes)}"
|
||||
)
|
||||
# Verify values per task
|
||||
t1_writes = [w for w in tup.pending_writes if w[0] == t1 and w[1] == "ch"]
|
||||
t2_writes = [w for w in tup.pending_writes if w[0] == t2 and w[1] == "ch"]
|
||||
assert len(t1_writes) == 1, f"Expected 1 write from t1, got {len(t1_writes)}"
|
||||
assert len(t2_writes) == 1, f"Expected 1 write from t2, got {len(t2_writes)}"
|
||||
assert t1_writes[0][2] == "from_t1", f"t1 value: {t1_writes[0][2]!r}"
|
||||
assert t2_writes[0][2] == "from_t2", f"t2 value: {t2_writes[0][2]!r}"
|
||||
|
||||
|
||||
async def test_put_writes_preserves_task_id(saver: BaseCheckpointSaver) -> None:
|
||||
"""task_id in pending_writes matches what was passed to aput_writes."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
await saver.aput_writes(stored, [("ch", "val")], task_id)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert any(w[0] == task_id for w in tup.pending_writes)
|
||||
|
||||
|
||||
async def test_put_writes_preserves_channel_and_value(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Channel name + value round-trip."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
await saver.aput_writes(stored, [("my_channel", {"data": 123})], task_id)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
match = [w for w in tup.pending_writes if w[0] == task_id and w[1] == "my_channel"]
|
||||
assert len(match) == 1, f"Expected 1 write, got {len(match)}"
|
||||
assert match[0][2] == {"data": 123}, f"Value mismatch: {match[0][2]!r}"
|
||||
|
||||
|
||||
async def test_put_writes_task_path(saver: BaseCheckpointSaver) -> None:
|
||||
"""task_path parameter accepted without error."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
# Should not raise
|
||||
await saver.aput_writes(stored, [("ch", "v")], task_id, task_path="a:b:c")
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 1
|
||||
|
||||
|
||||
async def test_put_writes_idempotent(saver: BaseCheckpointSaver) -> None:
|
||||
"""Duplicate (task_id, idx) doesn't duplicate writes."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
await saver.aput_writes(stored, [("ch", "val")], task_id)
|
||||
await saver.aput_writes(stored, [("ch", "val")], task_id)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
assert len(tup.pending_writes) == 1, (
|
||||
f"Expected exactly 1 write total, got {len(tup.pending_writes)}"
|
||||
)
|
||||
# Should not have duplicated
|
||||
matching = [w for w in tup.pending_writes if w[0] == task_id and w[1] == "ch"]
|
||||
assert len(matching) == 1
|
||||
|
||||
|
||||
async def test_put_writes_special_channels(saver: BaseCheckpointSaver) -> None:
|
||||
"""ERROR and INTERRUPT channels handled correctly."""
|
||||
from langgraph.checkpoint.serde.types import ERROR, INTERRUPT
|
||||
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp = generate_checkpoint()
|
||||
stored = await saver.aput(config, cp, generate_metadata(), {})
|
||||
|
||||
task_id = str(uuid4())
|
||||
await saver.aput_writes(
|
||||
stored,
|
||||
[(ERROR, "something went wrong"), (INTERRUPT, {"reason": "human_input"})],
|
||||
task_id,
|
||||
)
|
||||
|
||||
tup = await saver.aget_tuple(stored)
|
||||
assert tup is not None
|
||||
assert tup.pending_writes is not None
|
||||
channels = {w[1] for w in tup.pending_writes}
|
||||
assert ERROR in channels
|
||||
assert INTERRUPT in channels
|
||||
# Verify values
|
||||
err_writes = [w for w in tup.pending_writes if w[0] == task_id and w[1] == ERROR]
|
||||
assert len(err_writes) == 1, f"Expected 1 ERROR write, got {len(err_writes)}"
|
||||
assert err_writes[0][2] == "something went wrong", (
|
||||
f"ERROR value: {err_writes[0][2]!r}"
|
||||
)
|
||||
int_writes = [
|
||||
w for w in tup.pending_writes if w[0] == task_id and w[1] == INTERRUPT
|
||||
]
|
||||
assert len(int_writes) == 1, f"Expected 1 INTERRUPT write, got {len(int_writes)}"
|
||||
assert int_writes[0][2] == {"reason": "human_input"}, (
|
||||
f"INTERRUPT value: {int_writes[0][2]!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_put_writes_across_namespaces(saver: BaseCheckpointSaver) -> None:
|
||||
"""Writes isolated by checkpoint_ns."""
|
||||
tid = str(uuid4())
|
||||
|
||||
# Root namespace checkpoint + write
|
||||
cfg_root = generate_config(tid, checkpoint_ns="")
|
||||
cp_root = generate_checkpoint()
|
||||
stored_root = await saver.aput(cfg_root, cp_root, generate_metadata(), {})
|
||||
root_task = str(uuid4())
|
||||
await saver.aput_writes(stored_root, [("ch", "root_val")], root_task)
|
||||
|
||||
# Child namespace checkpoint + write
|
||||
cfg_child = generate_config(tid, checkpoint_ns="child:1")
|
||||
cp_child = generate_checkpoint()
|
||||
stored_child = await saver.aput(cfg_child, cp_child, generate_metadata(), {})
|
||||
child_task = str(uuid4())
|
||||
await saver.aput_writes(stored_child, [("ch", "child_val")], child_task)
|
||||
|
||||
# Verify isolation — root should have exactly 1 write with root_val
|
||||
tup_root = await saver.aget_tuple(stored_root)
|
||||
assert tup_root is not None
|
||||
assert tup_root.pending_writes is not None
|
||||
root_ch = [w for w in tup_root.pending_writes if w[1] == "ch"]
|
||||
assert len(root_ch) == 1, f"Expected 1 root write, got {len(root_ch)}"
|
||||
assert root_ch[0][2] == "root_val", f"Root value: {root_ch[0][2]!r}"
|
||||
|
||||
# Child should have exactly 1 write with child_val
|
||||
tup_child = await saver.aget_tuple(stored_child)
|
||||
assert tup_child is not None
|
||||
assert tup_child.pending_writes is not None
|
||||
child_ch = [w for w in tup_child.pending_writes if w[1] == "ch"]
|
||||
assert len(child_ch) == 1, f"Expected 1 child write, got {len(child_ch)}"
|
||||
assert child_ch[0][2] == "child_val", f"Child value: {child_ch[0][2]!r}"
|
||||
|
||||
|
||||
async def test_put_writes_cleared_on_next_checkpoint(
|
||||
saver: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""New checkpoint starts with fresh pending_writes."""
|
||||
tid = str(uuid4())
|
||||
config = generate_config(tid)
|
||||
cp1 = generate_checkpoint()
|
||||
stored1 = await saver.aput(config, cp1, generate_metadata(step=0), {})
|
||||
|
||||
await saver.aput_writes(stored1, [("ch", "old_write")], str(uuid4()))
|
||||
|
||||
# New checkpoint
|
||||
config2 = generate_config(tid)
|
||||
config2["configurable"]["checkpoint_id"] = stored1["configurable"]["checkpoint_id"]
|
||||
cp2 = generate_checkpoint()
|
||||
stored2 = await saver.aput(config2, cp2, generate_metadata(step=1), {})
|
||||
|
||||
tup = await saver.aget_tuple(stored2)
|
||||
assert tup is not None
|
||||
# New checkpoint should have no pending writes
|
||||
writes = tup.pending_writes or []
|
||||
assert len(writes) == 0
|
||||
|
||||
|
||||
ALL_PUT_WRITES_TESTS = [
|
||||
test_put_writes_basic,
|
||||
test_put_writes_multiple_writes_same_task,
|
||||
test_put_writes_multiple_tasks,
|
||||
test_put_writes_preserves_task_id,
|
||||
test_put_writes_preserves_channel_and_value,
|
||||
test_put_writes_task_path,
|
||||
test_put_writes_idempotent,
|
||||
test_put_writes_special_channels,
|
||||
test_put_writes_across_namespaces,
|
||||
test_put_writes_cleared_on_next_checkpoint,
|
||||
]
|
||||
|
||||
|
||||
async def run_put_writes_tests(
|
||||
saver: BaseCheckpointSaver,
|
||||
on_test_result: Callable[[str, str, bool, str | None], None] | None = None,
|
||||
) -> tuple[int, int, list[str]]:
|
||||
"""Run all put_writes tests. Returns (passed, failed, failure_names)."""
|
||||
passed = 0
|
||||
failed = 0
|
||||
failures: list[str] = []
|
||||
for test_fn in ALL_PUT_WRITES_TESTS:
|
||||
try:
|
||||
await test_fn(saver)
|
||||
passed += 1
|
||||
if on_test_result:
|
||||
on_test_result("put_writes", test_fn.__name__, True, None)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
msg = f"{test_fn.__name__}: {e}"
|
||||
failures.append(msg)
|
||||
if on_test_result:
|
||||
on_test_result(
|
||||
"put_writes", test_fn.__name__, False, traceback.format_exc()
|
||||
)
|
||||
return passed, failed, failures
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Test utilities: checkpoint generators, assertion helpers, bulk operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import (
|
||||
ChannelVersions,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
)
|
||||
from langgraph.checkpoint.base.id import uuid6
|
||||
|
||||
|
||||
def generate_checkpoint(
|
||||
*,
|
||||
checkpoint_id: str | None = None,
|
||||
channel_values: dict[str, Any] | None = None,
|
||||
channel_versions: ChannelVersions | None = None,
|
||||
versions_seen: dict[str, ChannelVersions] | None = None,
|
||||
) -> Checkpoint:
|
||||
"""Create a well-formed Checkpoint with sensible defaults."""
|
||||
return Checkpoint(
|
||||
v=1,
|
||||
id=checkpoint_id or str(uuid6(clock_seq=-1)),
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
channel_values=channel_values if channel_values is not None else {},
|
||||
channel_versions=channel_versions if channel_versions is not None else {},
|
||||
versions_seen=versions_seen if versions_seen is not None else {},
|
||||
pending_sends=[], # ty: ignore[invalid-key]
|
||||
updated_channels=None,
|
||||
)
|
||||
|
||||
|
||||
def generate_config(
|
||||
thread_id: str | None = None,
|
||||
*,
|
||||
checkpoint_ns: str = "",
|
||||
checkpoint_id: str | None = None,
|
||||
) -> RunnableConfig:
|
||||
"""Create a RunnableConfig targeting a specific thread / namespace / checkpoint."""
|
||||
configurable: dict[str, Any] = {
|
||||
"thread_id": thread_id or str(uuid4()),
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
}
|
||||
if checkpoint_id is not None:
|
||||
configurable["checkpoint_id"] = checkpoint_id
|
||||
return {"configurable": configurable}
|
||||
|
||||
|
||||
def generate_metadata(
|
||||
source: str = "loop",
|
||||
step: int = 0,
|
||||
**extra: Any,
|
||||
) -> CheckpointMetadata:
|
||||
"""Create CheckpointMetadata with defaults."""
|
||||
md: dict[str, Any] = {"source": source, "step": step, "parents": {}}
|
||||
md.update(extra)
|
||||
return md
|
||||
|
||||
|
||||
async def put_test_checkpoint(
|
||||
saver: Any,
|
||||
*,
|
||||
thread_id: str | None = None,
|
||||
checkpoint_ns: str = "",
|
||||
parent_config: RunnableConfig | None = None,
|
||||
channel_values: dict[str, Any] | None = None,
|
||||
channel_versions: ChannelVersions | None = None,
|
||||
metadata: CheckpointMetadata | None = None,
|
||||
new_versions: ChannelVersions | None = None,
|
||||
) -> RunnableConfig:
|
||||
"""Put a single test checkpoint and return the stored config.
|
||||
|
||||
Handles wiring up parent_config, channel_values -> new_versions, etc.
|
||||
"""
|
||||
tid = thread_id or str(uuid4())
|
||||
cp = generate_checkpoint(
|
||||
channel_values=channel_values,
|
||||
channel_versions=channel_versions,
|
||||
)
|
||||
|
||||
# When channel_values are provided, ensure channel_versions + new_versions
|
||||
# are consistent so the checkpointer stores the blobs correctly.
|
||||
vals = channel_values or {}
|
||||
cv = channel_versions
|
||||
if cv is None and vals:
|
||||
cv = {k: 1 for k in vals}
|
||||
cp["channel_versions"] = cv
|
||||
nv = new_versions
|
||||
if nv is None:
|
||||
nv = cv or {}
|
||||
|
||||
md = metadata or generate_metadata()
|
||||
|
||||
config = generate_config(tid, checkpoint_ns=checkpoint_ns)
|
||||
if parent_config is not None:
|
||||
config["configurable"]["checkpoint_id"] = parent_config["configurable"][
|
||||
"checkpoint_id"
|
||||
]
|
||||
|
||||
return await saver.aput(config, cp, md, nv)
|
||||
|
||||
|
||||
async def put_test_checkpoints(
|
||||
saver: Any,
|
||||
*,
|
||||
n_threads: int = 1,
|
||||
n_checkpoints: int = 1,
|
||||
namespaces: list[str] | None = None,
|
||||
channel_values: dict[str, Any] | None = None,
|
||||
) -> list[RunnableConfig]:
|
||||
"""Convenience: put multiple checkpoints across threads/namespaces.
|
||||
|
||||
Returns the stored configs in insertion order.
|
||||
"""
|
||||
nss = namespaces or [""]
|
||||
stored: list[RunnableConfig] = []
|
||||
for t in range(n_threads):
|
||||
tid = f"thread-{t}"
|
||||
for ns in nss:
|
||||
parent: RunnableConfig | None = None
|
||||
for _c in range(n_checkpoints):
|
||||
cfg = await put_test_checkpoint(
|
||||
saver,
|
||||
thread_id=tid,
|
||||
checkpoint_ns=ns,
|
||||
parent_config=parent,
|
||||
channel_values=channel_values,
|
||||
)
|
||||
parent = cfg
|
||||
stored.append(cfg)
|
||||
return stored
|
||||
|
||||
|
||||
def assert_checkpoint_equal(
|
||||
actual: Checkpoint,
|
||||
expected: Checkpoint,
|
||||
*,
|
||||
check_channel_values: bool = True,
|
||||
) -> None:
|
||||
"""Assert two checkpoints are semantically equal."""
|
||||
assert actual["v"] == expected["v"], f"v mismatch: {actual['v']} != {expected['v']}"
|
||||
assert actual["id"] == expected["id"], (
|
||||
f"id mismatch: {actual['id']} != {expected['id']}"
|
||||
)
|
||||
assert actual["channel_versions"] == expected["channel_versions"], (
|
||||
"channel_versions mismatch"
|
||||
)
|
||||
assert actual["versions_seen"] == expected["versions_seen"], (
|
||||
"versions_seen mismatch"
|
||||
)
|
||||
if check_channel_values:
|
||||
assert actual["channel_values"] == expected["channel_values"], (
|
||||
"channel_values mismatch"
|
||||
)
|
||||
|
||||
|
||||
def assert_tuple_equal(
|
||||
actual: CheckpointTuple,
|
||||
expected: CheckpointTuple,
|
||||
*,
|
||||
check_writes: bool = True,
|
||||
check_channel_values: bool = True,
|
||||
) -> None:
|
||||
"""Assert two CheckpointTuples are semantically equal."""
|
||||
# Config
|
||||
a_conf = actual.config["configurable"]
|
||||
e_conf = expected.config["configurable"]
|
||||
assert a_conf["thread_id"] == e_conf["thread_id"], (
|
||||
f"thread_id mismatch: {a_conf['thread_id']} != {e_conf['thread_id']}"
|
||||
)
|
||||
assert a_conf.get("checkpoint_ns", "") == e_conf.get("checkpoint_ns", ""), (
|
||||
"checkpoint_ns mismatch"
|
||||
)
|
||||
assert a_conf["checkpoint_id"] == e_conf["checkpoint_id"], "checkpoint_id mismatch"
|
||||
|
||||
# Checkpoint
|
||||
assert_checkpoint_equal(
|
||||
actual.checkpoint,
|
||||
expected.checkpoint,
|
||||
check_channel_values=check_channel_values,
|
||||
)
|
||||
|
||||
# Metadata
|
||||
for k, v in expected.metadata.items():
|
||||
assert actual.metadata.get(k) == v, (
|
||||
f"metadata[{k}] mismatch: {actual.metadata.get(k)} != {v}"
|
||||
)
|
||||
|
||||
# Parent config
|
||||
if expected.parent_config is not None:
|
||||
assert actual.parent_config is not None, "expected parent_config, got None"
|
||||
assert (
|
||||
actual.parent_config["configurable"]["checkpoint_id"]
|
||||
== expected.parent_config["configurable"]["checkpoint_id"]
|
||||
), "parent checkpoint_id mismatch"
|
||||
else:
|
||||
assert actual.parent_config is None, (
|
||||
f"expected no parent_config, got {actual.parent_config}"
|
||||
)
|
||||
|
||||
# Pending writes
|
||||
if check_writes and expected.pending_writes is not None:
|
||||
assert actual.pending_writes is not None
|
||||
assert len(actual.pending_writes) == len(expected.pending_writes), (
|
||||
f"pending_writes length mismatch: {len(actual.pending_writes)} != {len(expected.pending_writes)}"
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Core conformance runner — detects capabilities, runs test suites, builds report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from langgraph.checkpoint.conformance.capabilities import (
|
||||
Capability,
|
||||
DetectedCapabilities,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.initializer import RegisteredCheckpointer
|
||||
from langgraph.checkpoint.conformance.report import (
|
||||
CapabilityReport,
|
||||
CapabilityResult,
|
||||
ProgressCallbacks,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_copy_thread import run_copy_thread_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_delete_for_runs import (
|
||||
run_delete_for_runs_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_delete_thread import (
|
||||
run_delete_thread_tests,
|
||||
)
|
||||
from langgraph.checkpoint.conformance.spec.test_get_tuple import run_get_tuple_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_list import run_list_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_prune import run_prune_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_put import run_put_tests
|
||||
from langgraph.checkpoint.conformance.spec.test_put_writes import run_put_writes_tests
|
||||
|
||||
# Maps capability to its runner function.
|
||||
_RUNNERS = {
|
||||
Capability.PUT: run_put_tests,
|
||||
Capability.PUT_WRITES: run_put_writes_tests,
|
||||
Capability.GET_TUPLE: run_get_tuple_tests,
|
||||
Capability.LIST: run_list_tests,
|
||||
Capability.DELETE_THREAD: run_delete_thread_tests,
|
||||
Capability.DELETE_FOR_RUNS: run_delete_for_runs_tests,
|
||||
Capability.COPY_THREAD: run_copy_thread_tests,
|
||||
Capability.PRUNE: run_prune_tests,
|
||||
}
|
||||
|
||||
|
||||
async def validate(
|
||||
registered: RegisteredCheckpointer,
|
||||
*,
|
||||
capabilities: set[str] | None = None,
|
||||
progress: ProgressCallbacks | None = None,
|
||||
) -> CapabilityReport:
|
||||
"""Run the validation suite against a registered checkpointer.
|
||||
|
||||
Args:
|
||||
registered: A RegisteredCheckpointer (from @checkpointer_test decorator).
|
||||
capabilities: If given, only run tests for these capability names.
|
||||
Otherwise, auto-detect and run all applicable tests.
|
||||
progress: Optional progress callbacks for incremental output.
|
||||
Use ``ProgressCallbacks.default()`` for dot-style,
|
||||
``ProgressCallbacks.verbose()`` for per-test output, or
|
||||
``None`` / ``ProgressCallbacks.quiet()`` for silent mode.
|
||||
|
||||
Returns:
|
||||
A CapabilityReport with per-capability results.
|
||||
"""
|
||||
report = CapabilityReport(checkpointer_name=registered.name)
|
||||
|
||||
# Determine which capabilities to test.
|
||||
caps_to_test: set[Capability]
|
||||
if capabilities is not None:
|
||||
caps_to_test = {Capability(c) for c in capabilities}
|
||||
else:
|
||||
caps_to_test = set(Capability)
|
||||
|
||||
async with registered.enter_lifespan():
|
||||
for cap in Capability:
|
||||
if cap in caps_to_test and cap.value not in registered.skip_capabilities:
|
||||
# Create a fresh checkpointer for each capability suite.
|
||||
async with registered.create() as saver:
|
||||
detected = DetectedCapabilities.from_instance(saver)
|
||||
is_detected = cap in detected.detected
|
||||
|
||||
if not is_detected:
|
||||
if progress and progress.on_capability_start:
|
||||
progress.on_capability_start(cap.value, False)
|
||||
report.results[cap.value] = CapabilityResult(
|
||||
detected=False,
|
||||
passed=None,
|
||||
tests_skipped=1,
|
||||
)
|
||||
continue
|
||||
|
||||
runner = _RUNNERS.get(cap)
|
||||
if runner is None:
|
||||
report.results[cap.value] = CapabilityResult(
|
||||
detected=True,
|
||||
passed=None,
|
||||
tests_skipped=1,
|
||||
)
|
||||
continue
|
||||
|
||||
if progress and progress.on_capability_start:
|
||||
progress.on_capability_start(cap.value, True)
|
||||
|
||||
passed, failed, failures = await runner(
|
||||
saver,
|
||||
on_test_result=progress.on_test_result if progress else None,
|
||||
)
|
||||
|
||||
if progress and progress.on_capability_end:
|
||||
progress.on_capability_end(cap.value)
|
||||
|
||||
report.results[cap.value] = CapabilityResult(
|
||||
detected=True,
|
||||
passed=failed == 0,
|
||||
tests_passed=passed,
|
||||
tests_failed=failed,
|
||||
failures=failures,
|
||||
)
|
||||
else:
|
||||
if progress and progress.on_capability_start:
|
||||
progress.on_capability_start(cap.value, False)
|
||||
report.results[cap.value] = CapabilityResult(
|
||||
detected=False,
|
||||
passed=None,
|
||||
tests_skipped=1,
|
||||
)
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,65 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-conformance"
|
||||
version = "0.0.1"
|
||||
description = "Conformance test suite for LangGraph checkpointer implementations."
|
||||
authors = [{name = "William FH", email = "13333726+hinthornw@users.noreply.github.com"}]
|
||||
requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-conformance"
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
]
|
||||
lint = [
|
||||
"ruff",
|
||||
"ty",
|
||||
]
|
||||
dev = [
|
||||
{include-group = "test"},
|
||||
{include-group = "lint"},
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ty.rules]
|
||||
# The extended methods (acopy_thread, adelete_for_runs, aprune) are checked
|
||||
# at runtime via capability detection and may not exist on the installed
|
||||
# base class. Dict literal inference is also overly strict for RunnableConfig.
|
||||
unresolved-attribute = "ignore"
|
||||
invalid-argument-type = "ignore"
|
||||
invalid-return-type = "ignore"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"E", # pycodestyle
|
||||
"F", # Pyflakes
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
url = "https://test.pypi.org/simple/"
|
||||
publish-url = "https://test.pypi.org/legacy/"
|
||||
explicit = true
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Self-tests: run the conformance suite against InMemorySaver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
from langgraph.checkpoint.conformance import checkpointer_test, validate
|
||||
|
||||
|
||||
@checkpointer_test(name="InMemorySaver")
|
||||
async def memory_checkpointer():
|
||||
yield InMemorySaver()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_memory_base():
|
||||
"""InMemorySaver passes all base capability tests."""
|
||||
report = await validate(memory_checkpointer)
|
||||
report.print_report()
|
||||
assert report.passed_all_base(), f"Base tests failed: {report.to_dict()}"
|
||||
Generated
+1124
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
.PHONY: test test_watch lint format
|
||||
.PHONY: test test_watch lint type format
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
@@ -61,6 +61,9 @@ lint lint_diff lint_package lint_tests:
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE)
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
@@ -550,6 +550,11 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
||||
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.
|
||||
"""
|
||||
is_pooled_conn = isinstance(self.conn, AsyncConnectionPool)
|
||||
# With AsyncConnectionPool, each _cursor() call checks out its own connection.
|
||||
# The pool does not hand out the same connection concurrently, so a shared lock
|
||||
# across calls is unnecessary here.
|
||||
lock = asyncio.Lock() if is_pooled_conn else self.lock
|
||||
async with _ainternal.get_connection(self.conn) as conn:
|
||||
if self.pipe:
|
||||
# a connection in pipeline mode can be used concurrently
|
||||
@@ -566,21 +571,21 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
|
||||
# thread/coroutine at a time, so we acquire a lock
|
||||
if self.supports_pipeline:
|
||||
async with (
|
||||
self.lock,
|
||||
lock,
|
||||
conn.pipeline(),
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
else:
|
||||
async with (
|
||||
self.lock,
|
||||
lock,
|
||||
conn.transaction(),
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
else:
|
||||
async with (
|
||||
self.lock,
|
||||
lock,
|
||||
conn.cursor(binary=True, row_factory=dict_row) as cur,
|
||||
):
|
||||
yield cur
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-checkpoint-postgres"
|
||||
version = "3.0.2"
|
||||
version = "3.0.4"
|
||||
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,15 +12,15 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.1.2,<4.0.0",
|
||||
"orjson>=3.10.1",
|
||||
"langgraph-checkpoint>=2.1.2,<5.0.0",
|
||||
"orjson>=3.11.5",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Source = "https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres"
|
||||
Twitter = "https://x.com/LangChainAI"
|
||||
Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user